มันเกิดขึ้นกับเราทุกคน เมื่อ Software Projects โตขึ้น ส่วนหนึ่งของ Codebase จบลงใน Production โดยไม่มี Test Suite ครบถ้วน เมื่อคุณมองโค้ดบริเวณเดิมอีกครั้งหลังจากผ่านไปหลายเดือน อาจเข้าใจยาก แย่กว่านั้น อาจมี Bug และเราไม่รู้จะเริ่มแก้จากไหน
การแก้ไขโค้ดโดยไม่มี Tests เป็น Challenge ใหญ่ เราไม่แน่ใจว่าจะ Break อะไรหรือไม่ และการตรวจสอบทุกอย่างด้วยมือเป็นแค่ Prone to Mistakes (น่าจะผิดพลาด) ปกติแล้ว Impossible
การจัดการกับโค้ดแบบนี้เป็นหนึ่งในงานทั่วไปที่สุดของ Developers และหลายเทคนิคมุ่งเน้นที่นี่มาหลายปี เช่น Characterization Tests ซึ่งเราพูดถึงในบทความก่อนหน้า
วันนี้ เราจะครอบคลุมเทคนิคอีกอย่างฐานบน Characterization Tests และ Introduced โดย Kent Beck ซึ่งเป็นคน Introduce TDD ให้โลกโปรแกรมมิ่งสมัยใหม่หลายปีก่อน
TCR คืออะไร?
TCR ย่อมาจาก "Test, Commit, Revert" แต่แม่นยำกว่าคือ "Test && Commit || Revert" มาดูเหตุผล
เทคนิคนี้อธิบาย Workflow สำหรับ Test Legacy Code เราจะใช้ Script ที่รัน Tests ทุกครั้งที่เรา Save Project Files กระบวนการคือ:
- แรก สร้าง Empty Unit Test สำหรับส่วนของ Legacy Code ที่เราอยาก Test
- เพิ่ม Assertion เดียว และ Save Test
- เนื่องจากเรา Setup Script ไว้ Test จะรันอัตโนมัติ ถ้า Pass การเปลี่ยนแปลงจะถูก Commit ถ้า Fail การเปลี่ยนแปลงจะถูกลบ (Revert) และเราต้องลองใหม่
เมื่อ Test Pass เราจึงเพิ่ม Test Case ใหม่ได้
โดยพื้นฐาน TCR คือการเก็บ Code ให้อยู่ในสถานะ "Green" แทนที่จะเขียน Failing Test ก่อน (Red) แล้วค่อยทำให้ Pass (Green) ตาม TDD หากเราเขียน Failing Test มันจะหายไป และเราจะถูกดึงกลับสู่สถานะ "Green" อีกครั้ง
วัตถุประสงค์
เป้าหลักคือเข้าใจโค้ดมากขึ้นทุกครั้งที่เพิ่ม Test Case ซึ่งจะเพิ่ม Test Coverage อย่างเป็นธรรมชาติ และปลดล็อก Refactorings หลายอย่างที่ไม่ทำได้ถ้าไม่มี Tests
ข้อดีของ TCR คือมีประโยชน์ในหลาย Scenario ใช้กับ Code ที่ไม่มี Tests เลย หรือ Code ที่ Tested เพียงบางส่วนได้ ถ้า Test ไม่ Pass เราแค่ Revert และลองใหม่
เราสามารถใช้ได้อย่างไร
Kent Beck แสดงใน Articles และ Videos หลายชิ้น (Link อยู่ท้าย) ว่า Approach ที่ดีคือใช้ Script ที่รันหลังจาก Save ไฟล์บางไฟล์ในโปรเจกต์
สิ่งนี้ขึ้นอยู่กับโปรเจกต์ที่คุณพยายาม Test โค้ดแบบนี้เป็นจุดเริ่มที่ดี:
(rspec && git commit -am "WIP") || git reset --hard ถ้าใช้ Visual Studio Code Plugin ดีสำหรับ Execute ทุก Save คือ "runonsave" คุณสามารถใส่คำสั่งข้างต้นหรือคำสั่งคล้ายๆ สำหรับโปรเจกต์ของคุณ Config File ทั้งหมดจะเป็น:
{ "folders": [{ "path": "." }], "settings": { "emeraldwalk.runonsave": { "commands": [ { "match": "*.rb", "cmd": "cd ${workspaceRoot} && rspec && git commit -am WIP || git reset --hard" } ] } } } จำไว้ว่าต่อไปคุณสามารถ Squash Commit ด้วย Git โดยตรงใน Command Line หรือตอน Merge PR ถ้าใช้ GitHub:
.
หมายความว่าเราจะได้ Commit เดียวใน Main Branch สำหรับ Commits ทั้งหมดที่เราทำบน Branch ที่ทำงาน Diagram จาก GitHub อธิบายดี:
.
เขียน Test แรกด้วย TCR
เราจะใช้ตัวอย่างง่ายเพื่ออธิบายเทคนิค มี Class ที่เรารู้ว่าทำงานได้ แต่เราต้อง Modify มัน
เราอาจแค่ทำการเปลี่ยนแปลงและ Deploy แต่เราอยากแน่ใจว่าไม่ Break อะไร ซึ่งเป็นไอเดียดีเสมอ
# worker.rb class Worker def initialize(age, active_years, veteran) @age = age @active_years = active_years @veteran = veteran end def can_retire? return true if @age >= 67 return true if @active_years >= 30 return true if @age >= 60 && @active_years >= 25 return true if @veteran && @active_years > 25 false end end ขั้นตอนแรกคือสร้างไฟล์ใหม่สำหรับ Tests เพื่อเริ่มเพิ่มพวกมัน เราเห็นบรรทัดแรกในเมธอด can_retire? คือ
def can_retire? return true if @age >= 67 ... ... end ดังนั้นเราสามารถ Test Case นี้ก่อน:
# specs/worker_spec.rb require_relative './../worker' describe Worker do describe 'can_retire?' do it "should return true if age is higher than 67" do end end end Tip: เมื่อทำงานกับ TCR ทุกครั้งที่ Save การเปลี่ยนแปลงล่าสุดจะหายถ้า Tests ไม่ Pass ดังนั้นเราอยากมี Code "Set Up" Test ให้มากที่สุดก่อนเขียนและ Save บรรทัดที่มี Assertion จริง
ถ้า Save ไฟล์ข้างต้นแบบนั้น เราจึงเพิ่มบรรทัดสำหรับ Test:
require_relative './../worker' describe Worker do describe 'can_retire?' do it "should return true if age is higher than 67" do expect(Worker.new(70, 10, false).can_retire?).to be_true ## บรรทัดนี้อาจหายตอน Save ตอนนี้ end end end ตอน Save ถ้าบรรทัดใหม่ไม่หาย เราทำได้ดี Test Pass!
เพิ่ม Tests เพิ่มเติม
เมื่อมี Test แรกแล้ว เราสามารถเพิ่ม Cases เพิ่มได้ โดยคำนึงถึง False Cases หลังจากทำงานสักพัก เราจะได้แบบนี้:
# frozen_string_literal: true require_relative './../worker' describe Worker do describe 'can_retire?' do it 'should return true if age is higher than 67' do expect(Worker.new(70, 10, false).can_retire?).to be true end it 'should return true if age is 67' do expect(Worker.new(67, 10, false).can_retire?).to be true end it 'should return true if age is less than 67' do expect(Worker.new(50, 10, false).can_retire?).to be false end it 'should return true if active years is higher than 30' do expect(Worker.new(60, 31, false).can_retire?).to be true end it 'should return true if active years is 30' do expect(Worker.new(60, 30, false).can_retire?).to be true end end end ในทุก Case เราเขียน Block "it" ก่อน Save แล้วค่อยเพิ่ม Assertion ด้วย expect(...)
เหมือนปกติ เราสามารถเพิ่ม Tests ได้มากเท่าที่จะทำได้ แต่ควรหลีกเลี่ยงการเพิ่มมากเกินไปเมื่อเราแทบแน่ใจว่า Cover ครบแล้ว
ยังมี Cases เหลืออยู่บ้าง ควรเพิ่มเพื่อ Completeness
Final Tests
นี่คือ Spec File ในรูปแบบสุดท้าย คุณเห็นได้ว่าเรายังเพิ่ม Cases ได้อีก แต่ผมคิดว่าพอจะ Illustrate กระบวนการ TCR แล้ว
# frozen_string_literal: true require_relative './../worker' describe Worker do describe 'can_retire?' do it 'should return true if age is higher than 67' do expect(Worker.new(70, 10, false).can_retire?).to be true end it 'should return true if age is 67' do expect(Worker.new(67, 10, false).can_retire?).to be true end it 'should return true if age is less than 67' do expect(Worker.new(50, 10, false).can_retire?).to be false end it 'should return true if active years is higher than 30' do expect(Worker.new(60, 31, false).can_retire?).to be true end it 'should return true if active years is 30' do expect(Worker.new(20, 30, false).can_retire?).to be true end it 'should return true if age is higher than 60 and active years is higher than 25' do expect(Worker.new(60, 30, false).can_retire?).to be true end it 'should return true if age is higher than 60 and active years is higher than 25' do expect(Worker.new(61, 30, false).can_retire?).to be true end it 'should return true if age is 60 and active years is higher than 25' do expect(Worker.new(60, 30, false).can_retire?).to be true end it 'should return true if age is higher than 60 and active years is 25' do expect(Worker.new(61, 25, false).can_retire?).to be true end it 'should return true if age is 60 and active years is 25' do expect(Worker.new(60, 25, false).can_retire?).to be true end it 'should return true if is veteran and active years is higher than 25' do expect(Worker.new(60, 25, false).can_retire?).to be true end end end Ways to Refactor
ถ้าคุณอ่านจนถึงที่นี่ อาจรู้สึกว่ามัน Off (แปลก) กับโค้ด เรามี "Magical Numbers" เยอะที่ควร Extract เป็น Constants ทั้งใน Test และ Worker Class
เราอาจสร้าง Private Methods สำหรับแต่ละ Case ใน Public Method can_retire? หลัก
ผมทิ้ง Refactorings ทั้งสองเป็น Exercises ให้คุณ แต่ตอนนี้เรามี Tests แล้ว ถ้าเราทำผิดในขั้นไหน Tests จะบอกเรา
สรุป
ผมขอแนะนำให้ลอง TCR กับโปรเจกต์ของคุณ มันเป็นการทดลองที่ "Cheap" (ต้นทุนต่ำ) เพราะไม่ต้องการ CI Fancy บน External Server หรือ Dependency กับ Library ใหม่ ทั้งหมดที่ต้องการคือวิธี Execute Command ทุกครั้งที่ Save ไฟล์บนเครื่อง
มันยังให้ประสบการณ์แบบ "Gaming" เมื่อเพิ่ม Tests ซึ่งสนุกและน่าสนใจ นอกจากนี้ Discipline ที่ Failing Tests ถูกลบจาก Editor จะให้ Safety Net เพิ่มเติมโดยยืนยันว่า Tests ที่ Push เข้า Repository เป็น Passing
หวังว่าคุณจะพบเทคนิคใหม่นี้มีประโยชน์เมื่อจัดการ Legacy Code ผมใช้หลายครั้งในเดือนๆ ที่ผ่านมา และมันเป็นความสุขเสมอ
Extra Resources
- Video ดีสำหรับ Introduction
- มุมมองของ Kent Beck เกี่ยวกับการใช้ TCR ใน VS Code
- VS Code Plugin สำหรับ Run Script on Save