Babel Coder

Refactor code ไม่ยาก แค่เขียน Unit test ตอนที่ 1

intermediate

ณ อำเภอบางพลัด สามีภรรยาคู่หนึ่งได้จดทะเบียนอย่ากัน…

สามวันก่อนหน้า สามีได้ซื้อเครื่องเกม 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

class ActiveRecord
  def all
    # เชื่อมต่อฐานข้อมูลเพื่อดึง records ทั้งหมดออกมา
  end
  
  def delete(id)
    # เชื่อมต่อฐานข้อมูลเพื่อลบ record ที่มี ID ตรงกับ id
  end
  
  def create(fields)
    # เชื่อมต่อฐานข้อมูลเพื่อสร้าง record โดยอาศัยข้อมูลที่รับเข้ามา
  end
  
  def where(conditions)
    # เชื่อมต่อฐานข้อมูลเพื่อค้นหา record ตามเงื่อนไข
  end
  
  # เมธอดอื่นๆ
end

เรามี Model ที่ต้องการรวมทั้ง Business Logic ทั้งเชื่อมต่อเพื่อเข้าถึงข้อมูลด้วย เราจึงสืบทอดต่อจาก ActiveRecord อุบไว้ก่อนว่านี่คืออีกส่วนปัญหาที่เราต้องแก้ไขกัน

class User < ActiveRecord
  attr_accessor :email, :name
  
  # callback ที่เรากำหนดขึ้นมาเองว่าหลังจาก create ให้เรียกเมธอดอะไร
  # พบได้ในหลายๆเฟรมเวิร์คเช่น Ruby on Rails, Laravel, อื่นๆ
  after_create :send_confirmation_email
  
  private
  
  def send_confirmation_email
    # หลังสร้าง User เสร็จให้ส่งอีเมล์ยืนยัน
  end
end

# หากเราอยากได้ผู้ใช้งานทั้งหมดจากฐานข้อมูล
# เราสามารถเรียก User.new.all ได้
# หรืออยากสร้างผู้ใช้งานใหม่ก็แค่เรียก User.new.create(email: 'xxx', password: 'yyy')

เมื่อไหร่ก็ตามที่ผู้ใช้งานวิ่งเข้ามาเว็บเราผ่าน /signup ด้วย HTTP POST พร้อมส่งข้อมูลของ User ที่ต้องการสร้าง เช่น email และ password เราจะทำการเรียกเมธอด create ในคลาส UserController ดังนี้

class UserController < Controller
  def create(params)
    # ค่าที่เราส่งเข้ามาผ่าน /signup ด้วย HTTP POST จะอยู่ใน params[:user]
    # ใน params จะมี :email, :password, :name
    
    # เข้ารหัส password ซะหน่อยก่อนเก็บลงฐานข้อมูล
    encrypted_password = bcrypt(params[:password])
    
    # ทำการสร้าง User
    user = User.new.create(params)
    
    # คืนค่ากลับเป็นเป็น JSON ด้วย HTTP 200 OK
    { json: user.json, status: :ok }
  end
end

และเจ้า 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 ของเราดังนี้

describe UserController do
  describe 'POST #create' do
    let(:controller) { UserController.new }
    # กำหนด user ขึ้นมา
    let(:user) { User.new(email: [email protected]', name: 'BabelCoder') }
    
    it 'creates the user correctly' do
      # หากโค้ดมีการเรียก User.create ให้คืนค่า user กลับออกไป
      allow_any_instance_of(User).to receive(:create).and_return(user)
      
      controller.create({
        email: [email protected]', 
        password: 'passw0rd', 
        name: 'BabelCoder'
      })
      
      # TODO: ตรวจสอบว่าการทำงานถูกต้อง
    end
  end
end

เรา 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 ได้ประกาศศักดา โค้ดของเราจึงเปลี่ยนไปเป็นเช่นนี้

class UserController < Controller
  # ส่ง User.new เข้าไปในชื่อของ user
  def create(user, params)
    encrypted_password = bcrypt(params[:password])
    
    created_user = user.create(params)
    
    { json: user.json, status: :ok }
  end
end

จะเห็นว่าตอนนี้ create ของเราจะเป็นอิสระจาก User ในแง่ที่ไม่ต้องจัดการสร้าง instance ของ User ขึ้นมาเอง มาดูกันว่าโค้ดที่เปลี่ยนไปของเราทำให้เทสเปลี่ยนไปเช่นไร

describe UserController do
  describe 'POST #create' do
    let(:controller) { UserController.new }
    # กำหนด user ขึ้นมา
    let(:user) { User.new(email: [email protected]', name: 'BabelCoder') }
    # เหมือนกับเราสร้างอ็อบเจ็กต์ที่มีเมธอดชื่อ create ที่คืนค่ากลับเป็น user
    # โดยต่อไปนี้ผมขอเรียกอ็อบเจ็กต์นี้ว่า repo
    let(:repo) { double('user', create: user) }
    
    it 'creates the user correctly' do
      controller.create(
        # ไม่ต้อง mock แบบเก่า
      	repo, 
        {
          email: [email protected]', 
          password: 'passw0rd', 
          name: 'BabelCoder'
        }
      )
      
      # TODO: ตรวจสอบว่าการทำงานถูกต้อง
    end
  end
end

จากตัวอย่างนี้แสดงให้เห็นว่า เราแค่สร้างอ็อบเจ็กต์หลอกๆชื่อ repo ขึ้นมา โดนอ็อบเจ็กต์นี้จะมีเมธอดชื่อ create เมื่อเรียกเมธอดนี้จะคืนค่า user ออกมา เราแค่ส่ง repo เข้าไปเป็นอาร์กิวเมนต์แรกของ create ก็พอ ไม่ต้องเขียนอะไรให้ยุ่งยากแบบเทสที่แล้วด้วย

แต่จากเทสนี้เราจะพบความยุ่งยากอยู่ข้อนึงนั่นคือ เราต้องส่ง repo เข้าไปในทุกครั้งที่เรียก create หากในอนาคตเรามีเมธอดอื่น เช่น show, destroy, และอื่นๆ เราก็ต้องรับ repo เข้าไปเป็นพารามิเตอร์ตัวแรกเสมอๆ ช่างบัดสบซะนี่กระไร

ย้าย repo ของเราไปอยู่ในส่วนของ constructor แทนคือดีงาม

class UserController < Controller
  # รับ repo เข้ามาใน constructor ซะเลย เมธอดอื่นๆจะได้ไม่ต้องรับ repo อีก
  def initialize(repo = User.new)
  end
  
  # เอา repo ออกจากพารามิเตอร์ตัวแรกซะ
  def create(params)
    encrypted_password = bcrypt(params[:password])
    
    created_user = repo.create(params)
    
    { json: user.json, status: :ok }
  end
end

อัพเดทโค้ด ก็มาอัพเดทเทสซิ ชีวิตดีงามขึ้นแค่ไหนลองดู~

describe UserController do
  describe 'POST #create' do
    let(:user) { User.new(email: [email protected]', name: 'BabelCoder') }
    let(:repo) { double('user', create: user) }
    
    # ส่ง repo เข้าไปใน constructor แทน
    let(:controller) { UserController.new(repo) }
    
    it 'creates the user correctly' do
      # create ของเราก็จะสวยงามขึ้นเยอะ
      controller.create({
        email: [email protected]', 
        password: 'passw0rd', 
        name: 'BabelCoder'
      })
      
      # TODO: ตรวจสอบว่าการทำงานถูกต้อง
    end
  end
end

ตรวจสอบการทำงานของ action

สิ่งที่เมธอด create ของเราทำนั่นคือสร้าง user ในฐานข้อมูลผ่านการเรียก repo.create จากนั้นจึงคืนค่า response กลับออกมา ถ้าเราอยากทราบว่า create ของเราทำงานถูกต้องหรือไม่ เราก็ต้องทดสอบว่าในฐานข้อมูลของเรามี user เกิดขึ้นหรือไม่ และ response ที่ส่งกลับมาจากเมธอดมี status เป็น :ok รึเปล่าใช่หรือไม่?

ในการทำ Unit Test แน่นอนว่าเราจะไม่ถามฐานข้อมูลว่า user ถูกสร้างจริงๆหรือไม่เพราะนั่นจะกลายเป็นการตรวจสอบว่าโค้ดของเมธอด create ในคลาส User ทำงานถูกหรือเปล่า แทนที่จะทดสอบเมธอดของเรา เมื่อเป็นเช่นนี้สิ่งที่เราต้องทดสอบจึงเป็นการตรวจสอบว่าเมธอดของเรามีการเรียก create ของ User ด้วยอาร์กิวเมนต์ที่ถูกต้องรึเปล่าแทน

describe UserController do
  describe 'POST #create' do
    let(:user) { User.new(email: [email protected]', name: 'BabelCoder') }
    let(:repo) { double('user', create: user) }
    
    # ส่ง repo เข้าไปใน constructor แทน
    let(:controller) { UserController.new(repo) }
    
    let(:user) do
      {
        email: [email protected]', 
        password: 'passw0rd', 
        name: 'BabelCoder'
      }
    end
    
    it 'creates the user correctly' do
      response = controller.create(user)
      
      # ตรวจสอบการทำงานว่า repo.create ต้องถูกเรียกด้วยอาร์กิวเมนต์ที่ระบุ
      # อย่าลืมว่าตอนนี้ repo ของเราแทนอ็อบเจ็กต์ของคลาส User
      expect(repo)
      	.to receive(:create)
      	.with(user.merge({ password: bcrypt(user.password) }))
        
      # และอย่าลืมตรวจสอบด้วยว่า response จากเมธอดนี้ถูกต้อง
      expect(response.json).to eq(user.json)
      expect(response.status).to eq(:ok)
    end
  end
end

กราบงามๆสามทีแบบเบญจางคประดิษฐ์ ตอนนี้โค้ดของเราสามารถอวดเพื่อนบ้านได้อย่างไม่อายใครแล้วหละ

จงใช้ Repository Pattern

ทีนี้เราย้ายกลับมาพิจารณา User กันบ้าง ด้วยตบะอันแรงกล้าที่สั่งสมมากว่าพันปี ทำให้เราทราบว่า User ของเราออกแบบมาอย่างไม่ดี ทำไมหนะรึ? ก็เพราะว่ามันสืบทอดมาจาก ActiveRecord ที่เป็นคลาสสำหรับการจัดการกับฐานข้อมูลยังไงหละ แปลไทยเป็นไทยอีกรอบก็คือ User ของเราจะไม่มีวันเป็นอิสระจากฐานข้อมูลเลย แย่หน่อยนะเผลอๆเราเพิ่มอะไรเข้าไปใน User เมื่อถึงเวลาเทสเราก็ต้องต่อ database ทั้งๆที่ใจไม่กล้าพอ~

เมื่อ User ผูกติดกับ ActiveRecord ที่เป็นคลาสสำหรับติดต่อฐานข้อมูล เพื่อให้ Unit Test ของเราราบลื่นเราจึงต้องตัดขาดความสัมพันธ์ของเราสองด้วยการไม่สืบทอด User มาจาก ActiveRecord อีกต่อไป ลาก่อย~

class User < ActiveRecord
  attr_accessor :email, :name
  after_create :send_confirmation_email
  
  private
  
  def send_confirmation_email
    # หลังสร้าง User เสร็จให้ส่งอีเมล์ยืนยัน
  end
end

เพื่อให้ ActiveRecord ของเราจำเพราะเพื่อค้นหาข้อมูลสำหรับ User เท่านั้น เราจะขอเรียกมันใหม่เป็น UserRepository

class UserRepository
  def all
    # เชื่อมต่อฐานข้อมูลเพื่อดึง records ทั้งหมดออกมา
  end
  
  def delete(id)
    # เชื่อมต่อฐานข้อมูลเพื่อลบ record ที่มี ID ตรงกับ id
  end
  
  def create(fields)
    # เชื่อมต่อฐานข้อมูลเพื่อสร้าง record โดยอาศัยข้อมูลที่รับเข้ามา
  end
  
  def where(conditions)
    # เชื่อมต่อฐานข้อมูลเพื่อค้นหา record ตามเงื่อนไข
  end
  
  # เมธอดอื่นๆ
end

มันก็จะดีงามหน่อยๆ

เพื่อให้ Unit Test ของเรายังคงสตรอง การเปลี่ยนแปลงจึงต้องเกิดขึ้น

describe UserController do
  describe 'POST #create' do
    let(:user) { User.new(email: [email protected]', name: 'BabelCoder') }
    let(:repo) { double('repo', create: user) }
    let(:controller) { UserController.new(repo) }
    
    let(:user) do
      {
        email: [email protected]', 
        password: 'passw0rd', 
        name: 'BabelCoder'
      }
    end
    
    it 'creates the user correctly' do
      response = controller.create(user)
      
      expect(repo)
      	.to receive(:create)
      	.with(user.merge({ password: bcrypt(user.password) }))
        
      expect(response.json).to eq(user.json)
      expect(response.status).to eq(:ok)
    end
  end
end

โดยสิ่งที่เราจะส่งให้เป็นค่าของ constructor สำหรับ UserController นั่นก็คือ UserRepository นั่นเอง

class UserController < Controller
  # รับ repo เข้ามาใน constructor ซะเลย เมธอดอื่นๆจะได้ไม่ต้องรับ repo อีก
  def initialize(repo = UserRepository.new)
  end
  
  def create(params)
    encrypted_password = bcrypt(params[:password])
    
    created_user = repo.create(params)
    
    { json: user.json, status: :ok }
  end
end

หยุดที่จะใช้ 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 ซะซิ!

class UserRepository
  after_create :send_confirmation_email 
  
  def all
    # เชื่อมต่อฐานข้อมูลเพื่อดึง records ทั้งหมดออกมา
  end
  
  def delete(id)
    # เชื่อมต่อฐานข้อมูลเพื่อลบ record ที่มี ID ตรงกับ id
  end
  
  def create(fields)
    # เชื่อมต่อฐานข้อมูลเพื่อสร้าง record โดยอาศัยข้อมูลที่รับเข้ามา
  end
  
  def where(conditions)
    # เชื่อมต่อฐานข้อมูลเพื่อค้นหา record ตามเงื่อนไข
  end
  
  private
  
  def send_confirmation_email
    # หลังสร้าง User เสร็จให้ส่งอีเมล์ยืนยัน
  end
end

แค่นี้เราก็เขียน Unit Test โดยทดสอบว่าเมื่อมีการสร้าง User แล้วต้องมีการส่งเมล์ด้วยเช่นกัน แต่ช้าก่อนครับ Unit Test ของเราก็จะมีปัญหาตามมา นั่นเพราะ Unit Test ของเราจะบอกว่า ส่งอีเมล์หลังสร้าง user แต่พอเรามาดูที่เมธอด create กลับไม่พบว่ามีบรรทัดไหนบอกเลยว่าต้องส่งอีเมล์… จะมารู้ตัวอีกทีก็ตอนเห้นว่ามีการใช้ callback ชื่อ after_create นี่หละ

เพราะ callback ทำให้โค้ดของ Unit Test ส่อความสับสนต่อโค้ดหลัก เราจึงควรหลีกเลี่ยงการใช้งาน Callback

class UserRepository
  def all
    
  end
  
  def delete(id)
    
  end
  
  def create(fields)
    # เรียกแบบตรงๆไปเลย
    send_confirmation_email
  end
  
  def where(conditions)
    
  end
  
  private
  
  def send_confirmation_email
    # หลังสร้าง User เสร็จให้ส่งอีเมล์ยืนยัน
  end
end

สรุป

หลังจากลากเลือดกันมาทั้งบทความจะพบว่าคลาส User และ UserController ของเราจะสามารถทำ Unit Testing ได้ง่ายขึ้น ทว่ายังมีหลายสิ่งที่เราควร Refactor เพื่อทำให้โค้ดของเราสวยงามขึ้นไปอีก เช่น UserRepository นั้นเราตั้งเป้าไว้แต่ต้นให้มันเป็นทางผ่านเพื่อเชื่อมต่อกับฐานข้อมูล แต่ตอนนี้เราเพิ่มการทำงานกับอีเมล์เข้าไปเป็นผลให้คลาสของเรามีการทำงานเหนือสิ่งที่เราตั้งเป้าเอาไว้คือการทำงานกับฐานข้อมูล และนี่คือสิ่งที่เราจะ Refactor กันต่อไปในบทความถัดไปครับ โปรดติดตามและตามติดๆ


แสดงความคิดเห็นของคุณ


No any discussions