Refactor code ไม่ยาก แค่เขียน Unit test ตอนที่ 1
ณ อำเภอบางพลัด สามีภรรยาคู่หนึ่งได้จดทะเบียนอย่ากัน...
สามวันก่อนหน้า สามีได้ซื้อเครื่องเกม PS4 หอบกลับบ้าน น้ำลายไหลตลอดทางด้วยความกระหายจะได้เล่น ความที่ภรรยาอยากปกป้องชีวิตลูกจากอสรพิษที่ชื่อว่าเกมอย่างสุดชีวิต หมอผีจึงเป็นทางออกในฐานะที่ปรึกษา
"น่าเห็นใจครับ คุณต้องปกป้องลูกจากเกมอย่างสุดชีวิต พกถุงยาง อย่าบะบะโอ้บะบะกับเขาอีก และเลิกกับผัวซะ" หมอผีนั่งเทียนพรรษาพร้อมกับแนะนำอย่างมั่วซั่ว ถ้าสถานการณ์เช่นนี้เกิดกับคุณ คำแนะนำของหมอผีคือสิ่งที่เชื่อถือได้หรือไม่?
หมอผีนั่นมีหลักสูตรหลายสาขาครับ คุณมีปัญหาเรื่องลูก(กรอก) แต่ไปปรึกษาหมอผีแผนกเวชศาสตร์ผีตายโหง ปรึกษากันข้ามประเภทเช่นนี้คำตอบคงเชื่อถือได้ยาก
สถานการ์ณของการเขียนโปรแกรมก็เช่นกัน หากคุณต้องการปรับปรุงโค้ดให้ดีขึ้นด้วยการ Refactor คุณก็ควรปรึกษาผู้ช่วยที่ไว้ใจได้ แน่นอนว่ามีหลายสิ่งที่ช่วยได้ (แต่ไม่ใช่หมอผีแน่ๆ) และสิ่งที่จะเป็นผู้ช่วยของเราในบทความนี้นั่นก็คือ Unit Testing
ทำไม Unit Testing จึงสำคัญ
Unit testing ตามชื่อแล้วคือการทดสอบหน่วยเล็กย่อยๆของโปรแกรมเรา ถ้าเปรียบกับร่างกายเสมือนว่าเราทดสอบการทำงานหรือพฤติกรรณของอวัยวะแต่ละส่วน เช่นทดสอบว่าตับทำงานได้ปกติ แต่ไม่ใช่ทดสอบว่าเวลาอากาศร้อน พอตับแตกแล้วม้ามต้องแตกตาม เพราะนั่นคือการทดสอบ Unit หลายๆหน่วยพร้อมกัน ไม่ใช่ทีละส่วน
จากความเข้าใจข้างต้น เราจึงกล่าวได้ว่าสิ่งต่อไปนี้ไม่ใช่ Unit Test
- ทดสอบเมธอดนี้ทีไร ต้องต่อเน็ตไปคุยกับ Service ข้างนอกทุกที
- ขาด Database เหมือนขาดใจ ขาดเธอทีไรหาใหม่ทุกที~ เอาเป้นว่าจะเทสทีต้องเชื่อมฐานข้อมูลทุกครั้ง
- จะเทสคลาส Foo ซะหน่อย แต่ต้องมานั่งประกอบคลาส Bar, Zoo และอื่นๆซะก่อน #มันก็จะหน่อมแน้มหน่อยๆ
เมื่อ Unit Test คือการทดสอบหน่วยย่อยๆ การเขียนโค้ดเพื่อให้ทำ Unit Test ได้ง่าย เราจึงได้ประโยชน์จากมันตามไปด้วย ว่าแต่หากเราออกแบบโค้ดให้เขียน Unit Test ได้ง่าย จะได้ประโยชน์อะไรบ้างละ?
เมื่อโค้ดถูกออกแบบให้ทดสอบทีละส่วนได้ง่าย ย่อมหมายความว่ามันเป็นอิสระจากส่วนอื่นมากที่สุด นั่นคือเราสามารถนำโค้ดส่วนนี้ที่เป็น Unit ของเราไป Reuse ใช้หลายๆที่ได้ นอกจากนี้การที่มันเป็นอิสระมันจึงไม่ผูกพันกับส่วนใดๆ เราจึงไม่ต้องกังวลว่าเมื่อส่วนอื่นของโปรแกรมเราผิดพลาด จะทำให้ Unit นี้ของเราพินาศสันตะโรตามไปด้วย
จินตนาการถึงลำไส้ใหญ่ครับ ลำไส้ใหญ่นั้นเป็นทางผ่านของกากอาหารหรือ "อึ" ที่ส่งต่อมาจากลำไส้เล็ก หากวันนึงเราเผลอกินระเบิดเข้าไป นอกจากลำไส้เล็กจะดูดกลืนดินปืนไปเลี้ยงร่างกายไม่ได้แล้ว มันยังเป็นภาระกับลำไส้ใหญ่ เพราะเราจะ "ขี้ไม่ออก" อีกด้วย เพราะว่าลำไส้ใหญ่ขึ้นตรงต่อลำไส้เล็ก เมื่อลำไส้เล็กอุดตัน ลำไส้ใหญ่หรือจะรอด?
ในมุมกลับ หากลำไส้ใหญ่เป็นอิสระ เราสามารถ Reuse มันไปใช้ในส่วนอื่นก็ย่อมได้ เช่นใช้ลำไส้ใหญ่แทนปาก และในสถานการณ์ข้างต้นเมื่อเราพบว่าลำไส้เล็กมีปัญหา เราก็แค่นำลำไส้เล็กตัวเดิมทิ้งไป แล้วเอาตัวใหม่มาต่อกับลำไส้ใหญ่แทน นั่นเพราะลำไส้ใหญ่เป็นอิสระจากลำไส้เล็ก จะแทนที่ด้วยไส้อั่วก็ได้ โตแล้ว...แม่ไม่ว่า
โค้ดที่ยุ่งเหยิง
และนี่คือตัวอย่างของโค้ดที่ยุ่งเหยิงที่เราจะทำการ Refactor ด้วย Unit Test กันในหัวข้อถัดๆไป ต้องบอกก่อนว่ามันคือรูปแบบโค้ดที่เขียนเพื่อให้เข้าใจหลักการ แต่เอาไป run จริงไม่ผ่านนะเออ
เรามีคลาสที่ทำการเชื่อมต่อกับฐานข้อมูลชื่อ ActiveRecord
1class ActiveRecord2 def all3 # เชื่อมต่อฐานข้อมูลเพื่อดึง records ทั้งหมดออกมา4 end56 def delete(id)7 # เชื่อมต่อฐานข้อมูลเพื่อลบ record ที่มี ID ตรงกับ id8 end910 def create(fields)11 # เชื่อมต่อฐานข้อมูลเพื่อสร้าง record โดยอาศัยข้อมูลที่รับเข้ามา12 end1314 def where(conditions)15 # เชื่อมต่อฐานข้อมูลเพื่อค้นหา record ตามเงื่อนไข16 end1718 # เมธอดอื่นๆ19end
เรามี Model ที่ต้องการรวมทั้ง Business Logic ทั้งเชื่อมต่อเพื่อเข้าถึงข้อมูลด้วย เราจึงสืบทอดต่อจาก ActiveRecord
อุบไว้ก่อนว่านี่คืออีกส่วนปัญหาที่เราต้องแก้ไขกัน
1class User < ActiveRecord2 attr_accessor :email, :name34 # callback ที่เรากำหนดขึ้นมาเองว่าหลังจาก create ให้เรียกเมธอดอะไร5 # พบได้ในหลายๆเฟรมเวิร์คเช่น Ruby on Rails, Laravel, อื่นๆ6 after_create :send_confirmation_email78 private910 def send_confirmation_email11 # หลังสร้าง User เสร็จให้ส่งอีเมล์ยืนยัน12 end13end1415# หากเราอยากได้ผู้ใช้งานทั้งหมดจากฐานข้อมูล16# เราสามารถเรียก User.new.all ได้17# หรืออยากสร้างผู้ใช้งานใหม่ก็แค่เรียก User.new.create(email: 'xxx', password: 'yyy')
เมื่อไหร่ก็ตามที่ผู้ใช้งานวิ่งเข้ามาเว็บเราผ่าน /signup
ด้วย HTTP POST พร้อมส่งข้อมูลของ User ที่ต้องการสร้าง เช่น email และ password เราจะทำการเรียกเมธอด create ในคลาส UserController
ดังนี้
1class UserController < Controller2 def create(params)3 # ค่าที่เราส่งเข้ามาผ่าน /signup ด้วย HTTP POST จะอยู่ใน params[:user]4 # ใน params จะมี :email, :password, :name56 # เข้ารหัส password ซะหน่อยก่อนเก็บลงฐานข้อมูล7 encrypted_password = bcrypt(params[:password])89 # ทำการสร้าง User10 user = User.new.create(params)1112 # คืนค่ากลับเป็นเป็น JSON ด้วย HTTP 200 OK13 { json: user.json, status: :ok }14 end15end
และเจ้า UserController
นี่หละครับจะเป็นส่วนแรกที่เราจะทดสอบกัน
ทดสอบ action ของ controller
เมธอด create
มองแว็บแรกก็รู้ทันทีครับว่าเราจะทดสอบโค้ดของเราไม่ได้เลยถ้าไม่ได้ต่อ database นั่นเป็นเพราะบรรทัดท้ายสุดของเรามีการเรียก User.create
ที่จะไปสร้างผู้ใช้งานในฐานข้อมูลนั่นเอง
แต่... เราเรียนรู้กันไปแล้วว่า Unit Test ต้องไม่เชื่อมต่อฐานข้อมูลในขณะทดสอบ แล้วแบบนี้เราจะ Unit Test เมธอดนี้ของเรายังไงดี?
ในโลกของการเทสเรามี stub / mock
ใช้เพื่อบอกว่าเมื่อไหร่ที่อ้างอิงถึงเมธอดที่กำหนดให้คืนค่าใดกลับออกไป เช่น allow_any_instance_of(User).to receive(:create).and_return(user)
เป็นการบอกว่าหากในโค้ดของเรามีการเข้าถึงเมธอด create ของอ็อกเจ็กต์จากคลาส User ให้คืนค่า user กลับออกไป
ด้วยพลานุภาพแห่งการ stub เราจึงได้โค้ดสำหรับการทดสอบ create ของเราดังนี้
1describe UserController do2 describe 'POST #create' do3 let(:controller) { UserController.new }4 # กำหนด user ขึ้นมา5 let(:user) { User.new(email: 'admin@babelcoder.com', name: 'BabelCoder') }67 it 'creates the user correctly' do8 # หากโค้ดมีการเรียก User.create ให้คืนค่า user กลับออกไป9 allow_any_instance_of(User).to receive(:create).and_return(user)1011 controller.create({12 email: 'admin@babelcoder.com',13 password: 'passw0rd',14 name: 'BabelCoder'15 })1617 # TODO: ตรวจสอบว่าการทำงานถูกต้อง18 end19 end20end
เรา stub create
เอาไว้ ทำให้ทุกครั้งที่เรียก User.new.create จะได้ค่า user กลับออกมาเสมอ ดีจังแค่นี้ก็ไม่ต้องเทสแบบต่อฐานข้อมูลแล้ว
ทว่า ในโค้ดของเราอาจมีการเรียก User.new.create หลายครั้งก็ได้ และการ stub ของเราครั้งนี้ไม่ว่าจะเรียก User.new.create ที่ไหน ก็จะคืนค่า user กลับมาเสมอ แบบนี้แม่ไม่ปลื้มแน่ อย่างน้อยๆถ้า arguments เปลี่ยนก็ควรได้ค่าใหม่กลับมา (จริงๆการ stub / mock สามารถควบคุมได้มากกว่าที่แสดงในตัวอย่างครับ ควบคุม arguments ก็ได้ แต่คุมคนด้วยกระบอกปืนไม่ได้ก็แค่นั้น)
สาเหตุที่ทำให้ Unit Test ส่วนนี้่ของเรายุ่งเหยิงนั่นก็เพราะ UserController#create
ของเราไม่เป็นอิสระจากคลาส User นั่นเอง หากเราทำให้เมธอดของเราเป็นอิสระจากคลาสดังกล่าวได้ การทดสอบโปรแกรมของเราจะง่ายขึ้น
เราจะรับคลาส User
ของเราเข้ามาผ่าน parameters แทนครับ เราถือว่าคลาสดังกล่าวคือ Dependency หรือเป็นสิ่งที่เมธอดของเราต้องการ แต่เมธอดของเราจะไม่จัดการมันด้วยตัวเอง เราจะให้โลกภายนอกส่งหรือ Inject เข้ามา และนี่คือหลักการของ Dependency Injection
ลองจินตนาการถึงแผนก IT ที่เก่งเฉพาะเรื่องคอมดูครับ หากให้แผนก IT มานั่งรับสมัครงานเอง หาคนเข้าทีมเอง โอ๊ยยุ่งยาก เสียเวลาเล่น RoV ชิบ จะดีกว่าไหมถ้าเราจะให้หน้าที่หาคนเข้าทีมเป็นภาระของ HR แทน เมื่อไหร่ที่ HR หาคนที่มีคุณสมบัติพร้อมก็ Inject หรือส่งเข้ามาในแผนก IT ซะเลย นี่จึงทำให้แผนก IT ของเราคล่องตัวและเป็นอิสระจากภาระในการหาคนสิ้นเชิง
เมื่อ Dependency Inject ได้ประกาศศักดา โค้ดของเราจึงเปลี่ยนไปเป็นเช่นนี้
1class UserController < Controller2 # ส่ง User.new เข้าไปในชื่อของ user3 def create(user, params)4 encrypted_password = bcrypt(params[:password])56 created_user = user.create(params)78 { json: user.json, status: :ok }9 end10end
จะเห็นว่าตอนนี้ create
ของเราจะเป็นอิสระจาก User ในแง่ที่ไม่ต้องจัดการสร้าง instance ของ User ขึ้นมาเอง มาดูกันว่าโค้ดที่เปลี่ยนไปของเราทำให้เทสเปลี่ยนไปเช่นไร
1describe UserController do2 describe 'POST #create' do3 let(:controller) { UserController.new }4 # กำหนด user ขึ้นมา5 let(:user) { User.new(email: 'admin@babelcoder.com', name: 'BabelCoder') }6 # เหมือนกับเราสร้างอ็อบเจ็กต์ที่มีเมธอดชื่อ create ที่คืนค่ากลับเป็น user7 # โดยต่อไปนี้ผมขอเรียกอ็อบเจ็กต์นี้ว่า repo8 let(:repo) { double('user', create: user) }910 it 'creates the user correctly' do11 controller.create(12 # ไม่ต้อง mock แบบเก่า13 repo,14 {15 email: 'admin@babelcoder.com',16 password: 'passw0rd',17 name: 'BabelCoder'18 }19 )2021 # TODO: ตรวจสอบว่าการทำงานถูกต้อง22 end23 end24end
จากตัวอย่างนี้แสดงให้เห็นว่า เราแค่สร้างอ็อบเจ็กต์หลอกๆชื่อ repo
ขึ้นมา โดนอ็อบเจ็กต์นี้จะมีเมธอดชื่อ create
เมื่อเรียกเมธอดนี้จะคืนค่า user ออกมา เราแค่ส่ง repo
เข้าไปเป็นอาร์กิวเมนต์แรกของ create ก็พอ ไม่ต้องเขียนอะไรให้ยุ่งยากแบบเทสที่แล้วด้วย
แต่จากเทสนี้เราจะพบความยุ่งยากอยู่ข้อนึงนั่นคือ เราต้องส่ง repo เข้าไปในทุกครั้งที่เรียก create หากในอนาคตเรามีเมธอดอื่น เช่น show, destroy, และอื่นๆ เราก็ต้องรับ repo เข้าไปเป็นพารามิเตอร์ตัวแรกเสมอๆ ช่างบัดสบซะนี่กระไร
ย้าย repo ของเราไปอยู่ในส่วนของ constructor
แทนคือดีงาม
1class UserController < Controller2 # รับ repo เข้ามาใน constructor ซะเลย เมธอดอื่นๆจะได้ไม่ต้องรับ repo อีก3 def initialize(repo = User.new)4 end56 # เอา repo ออกจากพารามิเตอร์ตัวแรกซะ7 def create(params)8 encrypted_password = bcrypt(params[:password])910 created_user = repo.create(params)1112 { json: user.json, status: :ok }13 end14end
อัพเดทโค้ด ก็มาอัพเดทเทสซิ ชีวิตดีงามขึ้นแค่ไหนลองดู~
1describe UserController do2 describe 'POST #create' do3 let(:user) { User.new(email: 'admin@babelcoder.com', name: 'BabelCoder') }4 let(:repo) { double('user', create: user) }56 # ส่ง repo เข้าไปใน constructor แทน7 let(:controller) { UserController.new(repo) }89 it 'creates the user correctly' do10 # create ของเราก็จะสวยงามขึ้นเยอะ11 controller.create({12 email: 'admin@babelcoder.com',13 password: 'passw0rd',14 name: 'BabelCoder'15 })1617 # TODO: ตรวจสอบว่าการทำงานถูกต้อง18 end19 end20end
ตรวจสอบการทำงานของ action
สิ่งที่เมธอด create ของเราทำนั่นคือสร้าง user ในฐานข้อมูลผ่านการเรียก repo.create
จากนั้นจึงคืนค่า response กลับออกมา ถ้าเราอยากทราบว่า create ของเราทำงานถูกต้องหรือไม่ เราก็ต้องทดสอบว่าในฐานข้อมูลของเรามี user เกิดขึ้นหรือไม่ และ response ที่ส่งกลับมาจากเมธอดมี status เป็น :ok รึเปล่าใช่หรือไม่?
ในการทำ Unit Test แน่นอนว่าเราจะไม่ถามฐานข้อมูลว่า user ถูกสร้างจริงๆหรือไม่เพราะนั่นจะกลายเป็นการตรวจสอบว่าโค้ดของเมธอด create
ในคลาส User ทำงานถูกหรือเปล่า แทนที่จะทดสอบเมธอดของเรา เมื่อเป็นเช่นนี้สิ่งที่เราต้องทดสอบจึงเป็นการตรวจสอบว่าเมธอดของเรามีการเรียก create
ของ User ด้วยอาร์กิวเมนต์ที่ถูกต้องรึเปล่าแทน
1describe UserController do2 describe 'POST #create' do3 let(:user) { User.new(email: 'admin@babelcoder.com', name: 'BabelCoder') }4 let(:repo) { double('user', create: user) }56 # ส่ง repo เข้าไปใน constructor แทน7 let(:controller) { UserController.new(repo) }89 let(:user) do10 {11 email: 'admin@babelcoder.com',12 password: 'passw0rd',13 name: 'BabelCoder'14 }15 end1617 it 'creates the user correctly' do18 response = controller.create(user)1920 # ตรวจสอบการทำงานว่า repo.create ต้องถูกเรียกด้วยอาร์กิวเมนต์ที่ระบุ21 # อย่าลืมว่าตอนนี้ repo ของเราแทนอ็อบเจ็กต์ของคลาส User22 expect(repo)23 .to receive(:create)24 .with(user.merge({ password: bcrypt(user.password) }))2526 # และอย่าลืมตรวจสอบด้วยว่า response จากเมธอดนี้ถูกต้อง27 expect(response.json).to eq(user.json)28 expect(response.status).to eq(:ok)29 end30 end31end
กราบงามๆสามทีแบบเบญจางคประดิษฐ์ ตอนนี้โค้ดของเราสามารถอวดเพื่อนบ้านได้อย่างไม่อายใครแล้วหละ
จงใช้ Repository Pattern
ทีนี้เราย้ายกลับมาพิจารณา User กันบ้าง ด้วยตบะอันแรงกล้าที่สั่งสมมากว่าพันปี ทำให้เราทราบว่า User ของเราออกแบบมาอย่างไม่ดี ทำไมหนะรึ? ก็เพราะว่ามันสืบทอดมาจาก ActiveRecord ที่เป็นคลาสสำหรับการจัดการกับฐานข้อมูลยังไงหละ แปลไทยเป็นไทยอีกรอบก็คือ User ของเราจะไม่มีวันเป็นอิสระจากฐานข้อมูลเลย แย่หน่อยนะเผลอๆเราเพิ่มอะไรเข้าไปใน User เมื่อถึงเวลาเทสเราก็ต้องต่อ database ทั้งๆที่ใจไม่กล้าพอ~
เมื่อ User ผูกติดกับ ActiveRecord ที่เป็นคลาสสำหรับติดต่อฐานข้อมูล เพื่อให้ Unit Test ของเราราบลื่นเราจึงต้องตัดขาดความสัมพันธ์ของเราสองด้วยการไม่สืบทอด User มาจาก ActiveRecord อีกต่อไป ลาก่อย~
1class User < ActiveRecord2 attr_accessor :email, :name3 after_create :send_confirmation_email45 private67 def send_confirmation_email8 # หลังสร้าง User เสร็จให้ส่งอีเมล์ยืนยัน9 end10end
เพื่อให้ ActiveRecord ของเราจำเพราะเพื่อค้นหาข้อมูลสำหรับ User เท่านั้น เราจะขอเรียกมันใหม่เป็น UserRepository
1class UserRepository2 def all3 # เชื่อมต่อฐานข้อมูลเพื่อดึง records ทั้งหมดออกมา4 end56 def delete(id)7 # เชื่อมต่อฐานข้อมูลเพื่อลบ record ที่มี ID ตรงกับ id8 end910 def create(fields)11 # เชื่อมต่อฐานข้อมูลเพื่อสร้าง record โดยอาศัยข้อมูลที่รับเข้ามา12 end1314 def where(conditions)15 # เชื่อมต่อฐานข้อมูลเพื่อค้นหา record ตามเงื่อนไข16 end1718 # เมธอดอื่นๆ19end
มันก็จะดีงามหน่อยๆ
เพื่อให้ Unit Test ของเรายังคงสตรอง การเปลี่ยนแปลงจึงต้องเกิดขึ้น
1describe UserController do2 describe 'POST #create' do3 let(:user) { User.new(email: 'admin@babelcoder.com', name: 'BabelCoder') }4 let(:repo) { double('repo', create: user) }5 let(:controller) { UserController.new(repo) }67 let(:user) do8 {9 email: 'admin@babelcoder.com',10 password: 'passw0rd',11 name: 'BabelCoder'12 }13 end1415 it 'creates the user correctly' do16 response = controller.create(user)1718 expect(repo)19 .to receive(:create)20 .with(user.merge({ password: bcrypt(user.password) }))2122 expect(response.json).to eq(user.json)23 expect(response.status).to eq(:ok)24 end25 end26end
โดยสิ่งที่เราจะส่งให้เป็นค่าของ constructor สำหรับ UserController นั่นก็คือ UserRepository นั่นเอง
1class UserController < Controller2 # รับ repo เข้ามาใน constructor ซะเลย เมธอดอื่นๆจะได้ไม่ต้องรับ repo อีก3 def initialize(repo = UserRepository.new)4 end56 def create(params)7 encrypted_password = bcrypt(params[:password])89 created_user = repo.create(params)1011 { json: user.json, status: :ok }12 end13end
หยุดที่จะใช้ Model Callback ซะ!
ถึงเวลาที่เราต้องทดสอบคลาส User กันอย่างจริงๆจังๆแล้วหละ ว่าแต่ User มีหน่วยย่อยอะไรให้เราเทสบ้าง? เมื่อชำเลืองมองเราก็จะพบ private method ที่ชื่อว่า send_confirmation_email
เร้นอยู่ในจุดซ่อนเร้น เอาวะ Unit Test มันซะเลยดีกว่า
โลกใบนี้มีคนอยู่สองประเภทครับ ประเภทแรกคือให้ตายข้าก็ไม่เทส private method กลุ่มคนประเภทนี้จะบอกว่า ในการใช้งานจริงเราเรียก private method ไม่ได้อยู่แล้ว เหตุนี้เราจึงไม่ควรจะไปเทสมันเพราะมันจะทำให้หลักการ Encapsulation ของเรากลายเป็นแค่คำโฆษณาเฉยๆ อีกอย่างบอกเลยแค่เทส public method มันก็ไปเรียก private method ตามมาอยู่แล้ว นี่เท่ากับเทส private method ไปในตัวแล้วนะเออ
ไม่ใช่สำหรับกลุ่มคนประเภทที่สองครับ ในเมื่อ private method ก็ซ่อน logic ไว้เหมือนกัน ทำไมเราจะไม่เทสมันหละ? แล้วถ้าไม่เทส มันเจ๊งขึ้นมาจะทำไง โลกนี้ไม่มีประกันชีวิตสำหรับงานเทสนะเห้ย โปรแกรมเจ๊งใครจะรับผิดชอบ?
เมื่อหาทางออกที่ลงตัวไม่ได้ เราจึงต้อง #ปรึกษาแมวแผล็บ... จริงๆคำตอบของปัญหานี้มีคำตอบที่ชัดเจนครับ แต่ผมขอละที่จะไม่กล่าวถึงในบทความนี้นะเพราะมันเกินขอบเขต
โชคดีมากครับที่เราไม่ต้องมาเถียงกันเรื่องนี้ นั่นเพราะ User ของเราไม่ผูกติดกับฐานข้อมูลอีกต่อไป ดังนั้น callback ประเภทที่ผูกติดกับฐานข้อมูล เช่น after_create
ที่บอกว่าหลังจากสร้าง record แล้วให้ทำอะไรต่อจึงมิอาจใช้ได้เช่นกัน
งั้นเราจะทำไงดี? ง่ายมากก็แค่ย้าย after_create
ไปไว้ใต้ UserRepository ซะซิ!
1class UserRepository2 after_create :send_confirmation_email34 def all5 # เชื่อมต่อฐานข้อมูลเพื่อดึง records ทั้งหมดออกมา6 end78 def delete(id)9 # เชื่อมต่อฐานข้อมูลเพื่อลบ record ที่มี ID ตรงกับ id10 end1112 def create(fields)13 # เชื่อมต่อฐานข้อมูลเพื่อสร้าง record โดยอาศัยข้อมูลที่รับเข้ามา14 end1516 def where(conditions)17 # เชื่อมต่อฐานข้อมูลเพื่อค้นหา record ตามเงื่อนไข18 end1920 private2122 def send_confirmation_email23 # หลังสร้าง User เสร็จให้ส่งอีเมล์ยืนยัน24 end25end
แค่นี้เราก็เขียน Unit Test โดยทดสอบว่าเมื่อมีการสร้าง User แล้วต้องมีการส่งเมล์ด้วยเช่นกัน แต่ช้าก่อนครับ Unit Test ของเราก็จะมีปัญหาตามมา นั่นเพราะ Unit Test ของเราจะบอกว่า ส่งอีเมล์หลังสร้าง user
แต่พอเรามาดูที่เมธอด create
กลับไม่พบว่ามีบรรทัดไหนบอกเลยว่าต้องส่งอีเมล์... จะมารู้ตัวอีกทีก็ตอนเห้นว่ามีการใช้ callback ชื่อ after_create
นี่หละ
เพราะ callback ทำให้โค้ดของ Unit Test ส่อความสับสนต่อโค้ดหลัก เราจึงควรหลีกเลี่ยงการใช้งาน Callback
1class UserRepository2 def all34 end56 def delete(id)78 end910 def create(fields)11 # เรียกแบบตรงๆไปเลย12 send_confirmation_email13 end1415 def where(conditions)1617 end1819 private2021 def send_confirmation_email22 # หลังสร้าง User เสร็จให้ส่งอีเมล์ยืนยัน23 end24end
สรุป
หลังจากลากเลือดกันมาทั้งบทความจะพบว่าคลาส User และ UserController ของเราจะสามารถทำ Unit Testing ได้ง่ายขึ้น ทว่ายังมีหลายสิ่งที่เราควร Refactor เพื่อทำให้โค้ดของเราสวยงามขึ้นไปอีก เช่น UserRepository นั้นเราตั้งเป้าไว้แต่ต้นให้มันเป็นทางผ่านเพื่อเชื่อมต่อกับฐานข้อมูล แต่ตอนนี้เราเพิ่มการทำงานกับอีเมล์เข้าไปเป็นผลให้คลาสของเรามีการทำงานเหนือสิ่งที่เราตั้งเป้าเอาไว้คือการทำงานกับฐานข้อมูล และนี่คือสิ่งที่เราจะ Refactor กันต่อไปในบทความถัดไปครับ โปรดติดตามและตามติดๆ
สารบัญ
- ทำไม Unit Testing จึงสำคัญ
- โค้ดที่ยุ่งเหยิง
- ทดสอบ action ของ controller
- ตรวจสอบการทำงานของ action
- จงใช้ Repository Pattern
- หยุดที่จะใช้ Model Callback ซะ!
- สรุป