ผมเขียนเทสอย่างไร (ณ ปลายปี 2019)

พี่คริสถามคำถามใน Facebook ว่า “เขียนเทสกันอย่างไร” ผมเลยได้โอกาสอัพเดตคำตอบสำหรับปี 2019 ซักที หลังจากทำงานมาครบ 10 ปีพอดี

การเขียนเทสของผมในปัจจุบันขึ้นอยู่กับ

  1. โดเมนงานว่าโปรแกรมที่เขียนอยู่เอาไปใช้ทำอะไร tolerate ต่อความผิดพลาดได้แค่ไหน
  2. เมื่อผิดพลาดแล้วเรา deploy fix ได้ง่ายและเร็วแค่ไหน
  3. เรามีระบบ monitoring และ alert พร้อมแค่ไหน

หลังๆ มานี้ผมชอบเขียนเทสให้เฉพาะกับ pure function ที่มี logic ซับซ้อน เพราะรู้สึกว่ามัน maintenance cost ต่ำและมีประโยชน์มาก

ผมเขียนเทสให้กับพวกโค้ดที่ทำ component wiring (integration, end-to-end) น้อยลง เพราะมันช้าและ maintenance cost สูง บางทีก็ต้องสร้าง abstraction มาเพื่อเปิดช่องให้เขียนเทสได้โดยเฉพาะ ใช้ monitoring ตรวจจับแล้วแก้ทีหลังเอา (โดเมนงานที่ทำอยู่เอื้อให้ทำได้)

เคยได้คำแนะนำนึงมาว่า ถ้าโค้ดตรงไหนเขียนเทสยาก ก็อาจจะไม่ต้องเขียนแต่ให้พยายามทำให้มันเป็น hot path ให้มันถูกเรียกใช้บ่อยๆ ถ้ามันเกิดพังเราจะได้รู้ได้เร็วๆ ยิ่งถ้า start ระบบไม่ขึ้นเลยยิ่งดี ต้องอย่าเอามันไปซ่อนไว้ใน conditional ลึกๆ เพราะจะทำให้เจอปัญหาช้า ผมคิดว่าเป็นแนวคิดที่น่าสนใจเหมือนกัน

มีตัวอย่างเกี่ยวกับเรื่องการเขียนเทสให้กับ pure function อยู่อันนึง เคยมีคนถามผมว่าถ้าเค้าทำ js form ที่มี input เป็น 100 ฟีลด์และมีเงื่อนไข validation แบบใช้หลายๆ input ร่วมกันตัดสินใจ จะมีวิธีการเขียนเทสอย่างไร ผมให้คำตอบเค้าไปว่า ให้เปลี่ยนมันเป็น pure function ซะ แทนที่เมื่อ validate แล้วจะทำ side-effect ทันที ให้ return ผลลัพธ์การตัดสินใจออกมาแทน แล้วให้ฟังก์ชันด้านนอกเป็นคนทำ side-effect หลังจากนั้นเราก็จะสามารถทำการเขียนเทสให้กับ pure function นั้นได้เต็มที่เลย จะกี่ร้อยเทสเคสก็ว่ากันไป

ทั้งนี้ทั้งนั้นการที่ผมสามารถเขียนเทสน้อยลงได้เยอะ เนื่องมาจากว่าผมมี tool ที่มาแบ่งงานจาก TDD ไป ก็คือ REPL ของภาษา Clojure ที่ผมใช้เขียนอยู่ในปัจจุบันนั่นเอง REPL แบ่งงานเหล่านี้จาก TDD ไปแล้ว

  • Fast development feedback loop
  • Help stay focus during development
  • API design tool
  • Run small unit / subset of program
  • Increase debuggability of program
Advertisements

ทำไม Clojure จึงยังไม่มี web framework หลัก

ทำไมต้องต่อต้าน one big framework ล่ะเนี่ย เวลาที่เปิด lib อื่นๆ แล้วมันมี interface ที่คุ้นเคยไม่ชอบกันเรอะ!?!!

เป็นคำถามของออฟ ขณะที่เรากำลังคุยกันเรื่อง Rails และโค้ดภาษา JavaScript ที่เราเจอๆ กันมาทาง twitter

ต่อไปนี้คือคำตอบของผมครับ

เพราะคนสร้างภาษาเค้าเสนอแนวคิดว่าเราควรทำความเข้าใจคำว่า simple กับ easy กันใหม่ และ one big framework ที่มีอยู่ในปัจจุบันมันตกอยู่ในกลุ่ม easy แต่ complect อะ https://www.infoq.com/presentations/Simple-Made-Easy

เค้าบอกว่า easy เกิดจากความคุ้นเคยซึ่งมัน relative กับพื้นฐานของแต่ละคนว่าเคยรู้อะไรมา แต่ simple คือสิ่งที่ทุกคนจะทำความเข้าใจได้ง่ายเหมือนๆ กัน (simple ในความหมายของเค้าคือแต่ละชิ้นเล็กๆแยกออกจากกันเด็ดขาดได้ นำกลับมารวมได้)

เค้าแนะนำให้เริ่มจากพยายามทำให้ software มัน simple ก่อนแล้วค่อย easy มาตามหลัง แต่ตอนนี้ใน clojure community ยังไม่มีใครทำได้อะ framework ที่ทั้ง simple และ easy ก็เลยไม่มี framework ที่ชนะ ทุกคนอาศัยความ simple ของ library ประกอบของกันขึ้นมาเองหมด

จากนั้นเราก็คุยกันต่ออีกนิดหน่อยว่า

ออฟ​: แต่เวลาที่เราสร้างอะไรซักอย่าง เราต้องการ abstraction ที่สูงพอนะ ไม่งั้นสร้างบ้านนี่ต้องไปเรียนรู้ตั้งแต่อะตอมเหล็กแล้วมาทำเหล็กเส้นอีก เราน่าจะต้องรู้ว่า “เอา components มาทำงานร่วมกันได้ยังไง” แต่ถ้าพูดถึง performance tuning อันนี้เห็นด้วยว่า simple ดีกว่าจริงๆ

ผม: อื้ม ถ้าใน Clojure abstraction ที่ทุกคนคุยกัน คือ data อะ ทุกไลบราลี (ยุคหลังๆ) จะมี api เป็น data หมด มันจะรับ input เป็น data (built-in datatype เช่น map,vector,list,set) แล้วก็ return output ออกมาเป็น data เหมือนกัน คราวนี้เราก็เอาไปประกอบกันได้ค่อนข้างง่ายอะ data manipulation

ออฟ: JS มันก็ simple นะ แต่มันไม่มีทางคาดเดาได้เลยว่า lib ตัวนี้มันจะมี inteface คล้ายกับที่เราเคยใช้รึเปล่า บางอันเน้นFP บางอันมาเป็นModule ต้องมานั่งลุ้นกันเป็นตัวๆ ไป

ผม: ใช่เลย Clojure มี data เป็น interface เหมือนๆ กันหมดอะ (clojure anti class/type ด้วย) เลยทำความเข้าใจได้ง่าย ฝั่ง JS ที่ถึงบางไลบราลีมี interface เป็น data แต่ถ้ามัน mutable มันก็จะเกิด abstraction leak อีก แต่ Clojure มันมี immutable data structure by default เลยไม่เกิดปัญหานั้น

จบบทสนทนาเพียงเท่านี้

ใครที่สงสัยว่า data เป็น api ได้อย่างไร ต่อไปนี้คือตัวอย่างบางส่วนครับ

Routing library:

["/api"
 ["/math" {:get {:parameters {:query {:x int?, :y int?}}
                 :responses {200 {:body {:total pos-int?}}}
                 :handler (fn [{{{:keys [x y]} :query} :parameters}]
                            {:status 200
                             :body {:total (+ x y)}})}}]]

HTML rendering:

[:div
 [:h3 "I am a component!"]
  [:p.someclass
   "I have " [:strong "bold"]
   [:span {:style {:color "red"}} " and red"]
   " text."]]

Datomic (database) query:

[:find ?year .
 :in $ ?name
 :where [?artist :artist/name ?name]
        [?artist :artist/startYear ?year]]

ใช้ every-pred แทน and และใช้ some-fn แทน or

ผมไม่ค่อยได้มีโอกาสใช้สองฟังก์ชันนี้บ่อยครั้งนัก วันนี้นึกขึ้นมาได้ว่ามันใช้สำหรับ compose predicate function เทียบได้กับ and และ or เลย

(def products 
  [{:id 1 :stocked true :price 85}
   {:id 2 :stocked true :price 200}
   {:id 3 :stocked false :price 50}
   {:id 4 :stocked false :price 150}])

(defn cheap? [p] (< p 100))

โค้ดสองบรรทัดต่อไปนี้ให้ผลลัพธ์เดียวกัน

(filter #(and (:stocked %) (cheap? (:price %))) products)
(filter (every-pred :stocked (comp cheap? :price)) products)

และโค้ดสองบรรทัดต่อไปนี้ให้ผลลัพธ์เดียวกัน

(filter #(or (:stocked %) (cheap? (:price %))) products)
(filter (some-fn :stocked (comp cheap? :price)) products)

พอสรุปได้แบบนี้แล้ว น่าจะได้หยิบมันมาใช้บ่อยขึ้น

cond กับ expensive get function

สมมติว่าฟังก์ชั่น fn1 เป็นฟังก์ชันที่คืนเป็นค่าใดๆจาก collection หรือคืนค่าเป็น nil แต่ฟังก์ชัน fn1 นี้ต้องใช้การประมวลผลเยอะ ผมไม่อยากรันมันหลายครั้งถ้าไม่จำเป็น พอมันต้องไปอยู่เป็นหนึ่งในเงื่อนไขของ cond ผมจึงไม่อยากทำแบบนี้เพราะผมต้องรันมัน 2 ครั้ง

(cond
  criteria1 return1
  criteria2 return2
  criteria1 return3
  (fn1) (fn1)
  :else some-other-thing)

ผมไม่อยาก let มันเอาไว้ก่อนเพราะเงื่อนไขนี้อาจจะไม่ต้องถูกคำนวณถ้าเงื่อนไขก่อนหน้าสำเร็จไปก่อนแล้ว เช่น ในกรณีนี้ criteria1, criteria2 หรือ criteria3 อาจจะเป็นจริงทำให้ fn1 ไม่จำเป็นต้องถูกรัน

(let [x (fn1)]
  (cond
    criteria1 return1
    criteria2 return2
    criteria3 return3
    x x
    :else some-other-thing))

ผมอยากได้อะไรคล้ายๆ กับ if-let หรือ when-let ที่ผมสามารถนำผลลัพธ์ที่ใช้ตรวจสอบเงื่อนไขไปใช้เป็นค่า return ได้ แต่ cond ไม่มีสิ่งนี้ให้ผมใช้

(cond
  criteria1 return1
  criteria2 return2
  criteria3 return3
  (let [x (fn1)] x) x
  :else some-other-thing)

ผมเจอปัญหานี้บ่อย คิดวิธีแก้ไม่ออกซักที แต่วันนี้ผมคิดออกแล้ว! เอา delay มาช่วยไง

(let [x (delay (fn1))]
  (cond
    criteria1 return1
    criteria2 return2
    criteria3 return3
    @x @x
    :else some-other-thing))

delay จะทำการห่อ form ที่อยู่ข้างใน ไม่ทำการ evaluate มันจนกว่าตัว delay จะถูก dereference ด้วย @ หรือ deref จากนั้นมันจะทำการเก็บผลลัพธ์ไว้ คืนค่าเดิมทุกครั้งเมื่อถูก dereference อีกในครั้งต่อๆ ไป

Immutable data ลดปัญหาในการเขียนโปรแกรมของเราได้อย่างไร

ทุกอย่างในโลกการเขียนโปรแกรมมีทั้งข้อดีและข้อเสีย ในโพสนี้ผมจะขอโฟกัสเฉพาะข้อดีของ immutable data นะครับ

มาเริ่มจากการดูโค้ดที่ไม่ได้ใช้ immutable data กันก่อน

def method1(obj1) # ตำแหน่งที่ 1
  # ...
  method2()
  obj1 # ตำแหน่งที่ 2
  # ...
end

โค้ดนี้มันมีความเป็นไปได้ที่ obj1 ณ ตำแหน่งที่ 2 จะถูกเปลี่ยนแปลงไปจากตอนที่เมท็อดรับมันมา ณ​ ตำแหน่งที่ 1 เรื่องนี้มันเป็นสาเหตุหนึ่งที่ทำให้เราต้องพยายามจำโค้ดของเราทั้งหมดว่ามีอะไรเกิดขึ้น ณ จุดใดบ้าง เราต้องรู้ว่าโค้ดก่อนหน้า อย่างเช่น method2 หรือก่อนหน้านั้น ไม่ได้ไปทำอะไรกับ obj1 ของเราในทางอ้อม ถ้าเราเขียนโค้ดดีก็อาจจะไม่มีการเปลี่ยนแปลงให้น่าปวดหัวแบบนั้นเกิดขึ้น แต่มันไม่มีอะไรรับประกันและป้องกันความผิดพลาดนี้ การเขียนโค้ดผิดพลาดมันเป็นเรื่องธรรมดาที่เกิดขึ้นได้อยู่แล้ว

เมื่อเราต้องการ debug โค้ดนี้ เราก็ต้องพยายามใส่ print ให้ใกล้กับตำแหน่งที่เราต้องการเช็คค่ามากที่สุด เพื่อที่เราจะได้มั่นใจได้ว่าเราได้เห็นค่าที่ถูกต้องจริง ๆ แต่มันก็ยังอาจจะไม่จริงเสมอไปเมื่อโปรแกรมของเรามี concurrency เช่น multi thread เข้ามาเกี่ยวข้อง ค่าที่ print ออกมาก็อาจจะยังไม่ใช่ค่าจริง ๆ ณ เวลานั้น ถึงเราจะใช้ debugger ช่วย มันก็มีปัญหาลักษณะเดียวกันอยู่ดี แถม debugger อาจจะไปรบกวนการทำงานของโปรแกรม ทำให้เราไม่เห็นค่าที่ถูกต้องได้อีกด้วย

Immutable data เข้ามาช่วยลดภาระของสมองของเราตรงนี้

(defn func1 [data1] ; ตำแหน่งที่ 3
  ; ...
  (func2)
  data1 ; ตำแหน่งที่ 4
  (let [data1 "something else"] ; ตำแหน่งที่ 5
    data1 ; ตำแหน่งที่ 6
    )
  ; ...
  )

เมื่อเรามาเขียนภาษาที่ data เป็น immutable ปัญหาที่ผมกล่าวมาก็จะหมดไป ค่าของ data1 ในตำแหน่งที่ 4 จะเท่ากับตำแหน่งที่ 3 เสมอ* ถึงแม้ว่าเราอาจจะสามารถเปลี่ยนให้ data1 ไปชี้ที่ค่าอื่นได้ (shadow) ดังเช่นในตำแหน่งที่ 5 ที่ทำให้ค่าของ data1 ในตำแหน่งที่ 6 ไม่เหมือนตำแหน่งที่ 4 แต่มันเป็นเพียงแค่การเปลี่ยน reference โดยที่ค่าของ data1 จากตำแหน่งที่ 3 และ 4 จะไม่ได้ถูกแตะต้อง เพียงแค่เราไม่สามารถเข้าถึงมันได้ในฟังก์ชันนี้แล้วเท่านั้น สังเกตว่าเราสามารถเห็นการเปลี่ยนแปลงได้ชัดเจนเสมอ เพราะมันไม่ได้ถูกซ่อนเอาไว้ เมื่อเป็นเช่นนี้ เราจะได้รับการการันตีว่าไม่ว่าเราจะตรวจค่าของ data1 ณ จุดใด มันจะเป็นค่าเดียวกับที่ถูกส่งมาให้ หรือเป็นค่าที่เราเป็นคนเปลี่ยนเองในฟังก์ชันนี้เสมอ

ผมมีตัวอย่างคลาสิคอีกหนึ่งอัน

def method3
  obj2 = SomeClass.new # ตำแหน่งที่ 7
  method4(obj2)
  obj2 # ตำแหน่งที่ 8
end

เช่นเดียวกันกับตัวอย่างแรก ในตัวอย่างนี้ไม่มีอะไรที่การันตีเราได้ว่า obj2 ในตำแหน่งที่ 8 มีค่าเหมือนกับ obj2 ในตำแหน่งที่ 7 ถ้าเราเขียนโค้ดไม่ดีก็มีโอกาสเกิดขึ้นได้เสมอ

(defn func3 []
  (let [data2 {:key "value"}] ; ตำแหน่งที่ 9
    (func4 data2)
    data2)) ; ตำแหน่งที่ 10

ในขณะที่โค้ดในตัวอย่างสุดท้ายนี้ data2 ในตำแหน่งที่ 9 และ 10 จะมีค่าเหมือนกันเสมอเพราะ data2 มันชี้ไปที่ immutable data ({:key "value"})

จากตั้งอย่างทั้งหมดจะพบว่าการที่เราเขียนโปรแกรมโดยใช้ immutable data ทำให้เราโฟกัสกับฟังก์ชันหรือเมท็อดที่เรากำลังอ่านและเขียนอยู่ได้ สามารถโยนความกังวลว่า object หรือ data ที่อยู่ตรงนี้จะถูกเปลี่ยนแปลงโดยไม่รู้ตัวหรือเปล่าทิ้งไป ลดภาระของสมองของเราเอาไปไว้คิดและจำส่ิงอื่น และลดความผิดพลาดในโปรแกรมของเราไปได้เยอะเลย

*ยกเว้น data1 จะเป็น Java object ที่ mutate ได้ ซึ่งในการเขียนโปรแกรมโดยปกติเราไม่ต้องใช้

Datomic time travel query กับ schema migration

Datomic เป็น database ที่เราสามารถ query จากสถานะของ database ในอดีตได้ เช่น query กับ database ขณะที่ data เป็นเมื่อ 3 อาทิตย์ก่อน ผมเคยมีข้อสงสัยกับคุณสมบัตินี้ว่ามันจะเวิร์คจริงหรอ เช่น ถ้าเราเอาโค้ดปัจจุบันไป query data ในอดีต หรือโค้ดในอดีตที่เรา revert กลับไป ไป query กับ database ปัจจุบันได้เสมอมั้ย (ถ้าใครเคยเขียน Rails อาจจะเคยเจอกรณีที่เมื่อ codebase ของเรา evolve ไปซักพัก เราจะไม่สามารถรัน migration ตั้งแต่ต้นได้ เพราะ model ของเรามีการเปลี่ยนแปลงไปจากวันที่เขียน migration นั้นๆ) หรือเราต้องมีการ sync code กับ database แต่ละช่วงเวลาหรือเปล่า

วันนี้ผมได้คำตอบแล้ว หลังจากได้ฟังคำอธิบายของ David Nolen จากช่วง AMA ของเขาในงาน ReactiveConf 2017

Continue reading

Clojure กับเครื่องหมายมากกว่าและเครื่องหมายน้อยกว่า

Twitter thread นี้คุยกันเรื่องเครื่องหมายมากกว่าและน้อยกว่า ผมได้เทคนิคที่มีประโยชน์กับผมมากเลยครับ

DG0IhziUAAE1Lg2.jpg

ผมเป็นคนที่มีประสบการณ์เดียวกับคนต้น tweet ที่ครูสมัยประถมสอนการจำเครื่องหมายมากกว่าและน้อยกว่าว่า เหมือนกับปาก ที่จะหันไปกินสิ่งที่มากกว่าเสมอ ซึ่งผมก็จำแบบนั้นมาตลอดชีวิตการเรียนวิชาเลข จนมาถึงการเขียนโปรแกรมด้วยภาษาอื่นๆ ที่เป็น infix notation ผมมองเห็นภาพปากหันไปด้านที่มากกว่าเสมอ

พอมาเขียน Clojure ซึ่งเป็น prefix notation ผมต้องใช้วิธีจินตนาการย้ายตำแหน่งของโอเปอเรเตอร์กลับไปอยู่ตรงกลางแบบ infix ทุกครั้ง ที่ก็ถูกต้อง แต่มันมีข้อเสียอยู่ตรงที่ผมกลายเป็นต้องคิด 2 จังหวะอยู่ตลอด ซึ่งมันช้า ในลักษณะเดียวกับที่เวลาเราจะพูดภาษาอังกฤษเราต้องคิดเป็นภาษาไทยก่อนแล้วค่อยๆ แปลเป็นภาษาอังกฤษนั่นแหละ

และโดยเฉพาะอย่างยิ่งโอเปอเรเตอร์ของ Clojure สามารถเปรียบเทียบได้มากกว่า 2 ค่าอีก เช่น (> 3 7 8 5) ผมต้องค่อยๆ มองทีละคู่ ยิ่งทำให้ช้าไปกันใหญ่

ใน twitter thread นี้มีการพูดถึงวิธีการมองที่เหมาะกับ prefix notation ของ Clojure มากกว่า โดยให้มองเป็นการเพิ่มของค่าและการลดของค่าแทน ดังนี้

มองที่ขีดบนของเครื่องหมาย < ซึ่งจะเป็น / หมายถึงการเพิ่มจากน้อยไปมาก ถ้าตัวเลขที่ตามมาเพิ่มขึ้นจากน้อยไปมาก แปลว่าเป็น true หากไม่เป็นไปตามนั้น แปลว่า false
(< 1 4 5 7 9)

เช่นเดียวกัน > คือ \ หมายถึงการลดลงจากมากไปน้อย ถ้าตัวที่ตามมาลดลงจากมากไปน้อย แปลว่า true หากไม่เป็นไปตามนั้น แปลว่า false
(> 8 7 3 2)

สำหรับผมแล้วมันง่ายและเร็วมากขึ้นมากเลยครับ