Một số chia sẻ về Test-Driven Developement căn bản

Một số chia sẻ về Test-Driven Developement căn bản

Năm 2020 vừa qua mình có một vài cái nhất từ trước đến giờ trong công việc Đi làm xa nhà nhất. Nói tiếng Anh nhiều nhất. Và viết test nhiều nhất...

Năm 2020 vừa qua là một năm đầy thay đổi với cả thế giới và tất nhiên với cả mình. Thế giới thay đổi theo chiều hướng tiêu cực do bệnh dịch. Tuy nhiên bản thân mình lại may mắn hơn khi sự thay đổi lại theo hướng tích cực (về mặt công việc).

Đi làm xa nhà nhất. Nói tiếng Anh nhiều nhất. Và viết test nhiều nhất, và đặc biệt được code mô hình TDD, một mô hình nghe thì nhiều tuy nhiên thực hành thì chưa từng. Thời gian đầu mới tiếp cận, mình gặp nhiều khó khăn, rồi dần dần cũng đâu vào đấy. Trong bài viết này mình muốn có vài chia sẻ mong có thể giúp đỡ những người mới bắt đầu đặt chân vào TDD bớt khó khăn.

Test-driven developement (TDD)

Test-driven developement (viết tắt là TDD) là một quy trình phát triển phần mềm dựa trên việc quy đổi yêu cầu thực tế thành các test case trước khi tiến hành lập trình. Quy trình này sẽ được lặp lại liên tục trong suốt quá trình phát triển phần mềm.

TDD được phát triển dựa trên khái niệm test-first programming, một trong những khái niệm cấu thành nên mô hình Extreme programming (XP) đang được áp dụng trong phát triển phần mềm linh hoạt (Agile). Extreme programming (XP) ra đời vào năm 1999. Tuy nhiên, TDD thực sự phát triển mạnh mẽ vào khoảng năm 2003 bởi một kĩ sư người Mỹ tên Kent Beck. Ông này cũng là tác giả của cuốn sách Test Driven Development: By Example.

Nguồn: https://en.wikipedia.org/wiki/Test-driven_development

Tiếp cận TDD sao cho dễ?

Nghe thì đơn giản, viết test, rồi viết code... Cơ mà khi bắt tay vào thực tế bản thân mình khá loay hoay. Điều kiện tiên quyết khi bạn muốn bắt tay vào TDD đó là:

Các bước tiến hành TDD

Về cơ bản chúng ta sẽ có các bước như sau.

  1. Tạo ra pha đỏ (red phase - vì lúc này thường output log thường sẽ có màu đỏ).
    1. Viết test spec, declare các functions, methods, properties, type, ... nếu cần thiết.
    2. Chạy test, đảm bảo không còn lỗi compile hoặc lỗi runtime nào. Nếu có quay lại bước trên.
  2. Biến pha đỏ thành pha xanh (green phase - vì lúc này thường output log thường sẽ có màu xanh).
    1. Code các functions, methods ... vừa declare, làm cho chúng chạy pass test (Make the right thing)
    2. Refactor đoạn code vừa viết nếu cần thiết (Make the thing right)
  3. Lặp lại từ bước 1.

Tương tự với việc fix bug.

  1. Mô phỏng lại lỗi thông qua test spec.
  2. Đảm bảo test spec failed với output đúng như tình huống thực tế.
  3. Sửa production code cho pass test spec mới.
  4. Refactor nếu cần thiết.

Một ví dụ nhỏ

Giả sử chúng ta có yêu cầu như sau:

  1. Chúng ta cần một class có tên Company
    1.1. Company cần lưu trữ một mảng các nhân viên, mỗi nhân viên cần 2 thông tin, ID và lương.
  2. Company cần cần có phương thức cho phép cập nhật lương cho nhân viên.
    2.1. Nếu tìm thấy nhân viên và cập nhật thành công, trả ra tên nhân viên vừa cập nhật.
    2.2. Nếu không tìm thấy nhân viên, trả ra thông báo lỗi 'Staff not found'.

Sau đây mình sẽ demo các bước TDD bằng JavaScript và môi trường NodeJS.

Setup môi trường chạy test

Khởi tạo môi trường node bằng các câu lệnh sau:

mkdir tdd-demo
cd tdd-demo
npm init -y

Chúng ta sẽ có cấu trúc file như sau:

.
├── node_modules
├── package-lock.json
├── package.json

Tiếp theo mình sẽ sử dụng một assertion library có tên Jasmine https://jasmine.github.io/.

npm i -D jasmine lodash

Tiếp theo khởi tạo cấu hình jasmine:

npx jasmine init

Cấu trúc file hiện tại sẽ là:

.
├── package-lock.json
├── package.json
└── spec
    └── support
        └── jasmine.json

Tạo ra 2 files có tên company.spec.jscompany.js trong thư mục tdd-demo:

touch company.spec.js
touch company.js

File company.js sẽ chứa production code, còn company.spec.js sẽ chứa test code. Cấu trúc file hiện tại:

.
├── company.js
├── company.spec.js
├── package-lock.json
├── package.json
└── spec
    └── support
        └── jasmine.json

Đến đây chúng ta đã có môi trường làm việc và có thể ...

...bắt tay vào TDD.

Tiến hành xử lý đồng thời yêu cầu 12.

  1. Chúng ta cần một class có tên Company
    1.1. Company cần lưu trữ một mảng các nhân viên, mỗi nhân viên cần 2 thông tin, ID và lương.

Đầu tiên là pha đỏ, tạo ra test spec. Hãy cập nhật nội dung file company.spec.js như sau:

const Company = require('./company.js').Company;
const lodash = require('lodash');

describe('Company', () => {
  let company;
  let staffs;

  beforeEach(() => {
    // Khởi tạo danh sách nhân viên
    staffs = [
      { id: 0, name: 'john', salary: 100 },
      { id: 1, name: 'jane', salary: 50 },
    ];
    // Khởi tạo company instance
    company = new Company(lodash.cloneDeep(staffs));
  });

  it('Should be initialized', () => {
    expect(company.staffs).toEqual(staffs);
  });
});

Với spec trên, chúng ta mong muốn rằng:

Khởi chạy test bằng câu lệnh

npx jasmine

Kết quả chạy test:

Randomized with seed 55401
Started
F

Failures:
1) Company Should be initialized
  Message:
    TypeError: Company is not a constructor
  Stack:
    TypeError: Company is not a constructor
        at UserContext.beforeEach (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.spec.js:15:15)
        at <Jasmine>
        at runCallback (timers.js:705:18)
        at tryOnImmediate (timers.js:676:5)
        at processImmediate (timers.js:658:5)
  Message:
    TypeError: Cannot read property 'staffs' of undefined
  Stack:
    TypeError: Cannot read property 'staffs' of undefined
        at UserContext.it (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.spec.js:19:20)
        at <Jasmine>
        at runCallback (timers.js:705:18)
        at tryOnImmediate (timers.js:676:5)
        at processImmediate (timers.js:658:5)

1 spec, 1 failure
Finished in 0.007 seconds
Randomized with seed 55401 (jasmine --random=true --seed=55401)

Lúc này các bạn có thể nhìn thấy lỗi màu đỏ từ console, tuy nhiên đây là lỗi build-time, chúng ta cần sửa lỗi này trước. Tiếp tục update company.js, chúng ta cần declare class Company và contructor() method.

class Company {
  constructor() {
  }
}

exports.Company = Company;

Cần nhớ chúng ta chỉ update production code đủ để test runner không còn báo lỗi. Tiếp tục thử chạy lại test với npx jasmine, lúc này kết quả vẫn sẽ có màu đỏ, tuy nhiên sẽ là báo lỗi assertion thay vì lỗi compile:

Randomized with seed 99414
Started
F

Failures:
1) Company Should be initialized
  Message:
    Expected undefined to equal [ Object({ id: 0, name: 'john', salary: 100 }), Object({ id: 1, name: 'jane', salary: 50 }) ].
  Stack:
    Error: Expected undefined to equal [ Object({ id: 0, name: 'john', salary: 100 }), Object({ id: 1, name: 'jane', salary: 50 }) ].
        at <Jasmine>
        at UserContext.it (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.spec.js:19:28)
        at <Jasmine>

1 spec, 1 failure
Finished in 0.009 seconds
Randomized with seed 99414 (jasmine --random=true --seed=99414)

Lúc này chúng ta đã hoàn thành pha đỏ, tiếp tục chuyển sang pha xanh, update production code để assertion pass. Sửa lại constructor như sau:

class Company {
- constructor() {
+ constructor(staffs) {
+   this.staffs = staffs;
  }
}

exports.Company = Company;

Và chạy lại test:

Randomized with seed 33955
Started
.

1 spec, 0 failures
Finished in 0.008 seconds
Randomized with seed 33955 (jasmine --random=true --seed=33955)

Lúc này test spec đã pass. Chúng ta có thể chuyển sang bước 2 của pha xanh, đó là refactor. Ví dụ, chúng ta có thể cải thiện construction của class để tránh thuộc tính staffs bị sửa đổi từ bên ngoài.

+ const lodash = require('lodash');

class Company {
  constructor(staffs) {
-   this.staffs = staffs;
+   this._staffs = staffs;
  }

+  get staffs() {
+    return lodash.cloneDeep(this._staffs);
+  }
}

exports.Company = Company;

Chạy lại test, đảm bảo test vẫn pass. Như vậy chúng ta cũng kết thúc phase xanh, đồng thời hoàn thành một chu kì TDD với yêu cầu 1 và 2.

Với yêu cầu số 3 và 3.1:

  1. Company cần cần có phương thức cho phép cập nhật lương cho nhân viên.
    3.1. Nếu tìm thấy nhân viên và cập nhật thành công, trả ra tên nhân viên vừa cập nhật.

Chúng ta lại tạo ra pha đỏ bằng việc update spec file.

const Company = require('./company.js').Company;
const lodash = require('lodash');

describe('Company', () => {
  let company;
  let staffs;

  beforeEach(() => {
    // Khởi tạo danh sách nhân viên
    staffs = [
      { id: 0, name: 'john', salary: 100 },
      { id: 1, name: 'jane', salary: 50 },
    ];
    // Khởi tạo company instance
    company = new Company(lodash.cloneDeep(staffs));
  });

  it('Should be initialized', () => {
    expect(company.staffs).toEqual(staffs);
  });

+  it('updateSalary() should update staff salary if staff exists', () => {
+    // Đảm bảo quá trình khởi tạo chính xác
+    expect(company.staffs[0].salary).toEqual(100);
+    expect(company.staffs[1].salary).toEqual(50);
+
+    // Thực thi hàm mới với ID tồn tại
+    const updateResult = company.updateSalary(1, 90);
+
+    // Assert giá trị thay đổi
+    expect(company.staffs[0].salary).toEqual(100);
+    expect(company.staffs[1].salary).toEqual(90);
+    expect(updateResult).toEqual(company.staffs[1].name);
+  });
});

Với spec mới thêm bên trên, chúng ta mong muốn class Company có thêm một phương thức mới có tên updateSalary với 2 parameters, một là ID nhân viên, 2 là mức lương mới.

Tiếp tục chạy test với lệnh npx jasmine:

Randomized with seed 54649
Started
.F

Failures:
1) Company updateSalary() should update staff salary if staff exist
  Message:
    TypeError: company.updateSalary is not a function
  Stack:
    TypeError: company.updateSalary is not a function
        at UserContext.it (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.spec.js:24:34)
        at <Jasmine>
        at runCallback (timers.js:705:18)
        at tryOnImmediate (timers.js:676:5)
        at processImmediate (timers.js:658:5)

2 specs, 1 failure
Finished in 0.011 seconds
Randomized with seed 54649 (jasmine --random=true --seed=54649)

Kết quả là lỗi compile, quay sang production để declare phương thức mới:

const lodash = require('lodash');

class Company {
  constructor(staffs) {
    this._staffs = staffs;
  }

  get staffs() {
    return lodash.cloneDeep(this._staffs);
  }

+  updateSalary(staffId, salary) {
+  }
}

exports.Company = Company;

Một lần nữa cần nhấn mạnh, chỉ thay đổi đủ để giải quyết lỗi compile, ở trường hợp này, chúng ta chỉ cần declare phương thức với các parameters cần thiết, không cần có bất cứ logic gì.

Tiếp tục chạy lại test:

Randomized with seed 60523
Started
.F

Failures:
1) Company updateSalary() should update staff salary if staff exist
  Message:
    Expected 50 to equal 90.
  Stack:
    Error: Expected 50 to equal 90.
        at <Jasmine>
        at UserContext.it (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.spec.js:27:38)
        at <Jasmine>
  Message:
    Expected undefined to equal 'jane'.
  Stack:
    Error: Expected undefined to equal 'jane'.
        at <Jasmine>
        at UserContext.it (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.spec.js:28:26)
        at <Jasmine>

2 specs, 1 failure
Finished in 0.011 seconds
Randomized with seed 60523 (jasmine --random=true --seed=60523)

Lỗi compile đã mất và lỗi assertion đã hiển thị. Giờ chuyển sang pha xanh, tiến hành implement method mới:

const lodash = require('lodash');

class Company {
  constructor(staffs) {
    this._staffs = staffs;
  }

  get staffs() {
    return lodash.cloneDeep(this._staffs);
  }

  updateSalary(staffId, salary) {
+    const staffToUpdate = this._staffs.find(s => s.id === staffId);
+    staffToUpdate.salary = salary;
+    return staffToUpdate.name;
  }
}

exports.Company = Company;

Chạy lại test:

Randomized with seed 85842
Started
..

2 specs, 0 failures
Finished in 0.01 seconds
Randomized with seed 85842 (jasmine --random=true --seed=85842)

Lần này chúng ta không cần refactor gì cả, có thể hoàn thành chu kì TDD cho yêu cầu 3.1 tại đây.

Với yêu cầu cuối cùng, yêu cầu 3.2:

3.2 Nếu không tìm thấy nhân viên, trả ra thông báo lỗi 'Staff not found'.

Chúng ta quay lại pha đỏ:

const Company = require('./company.js').Company;
const lodash = require('lodash');

describe('Company', () => {
  let company;
  let staffs;

  beforeEach(() => {
    staffs = [
      { id: 0, name: 'john', salary: 100 },
      { id: 1, name: 'jane', salary: 50 },
    ];
    company = new Company(lodash.cloneDeep(staffs));
  });

  it('Should be initialized', () => {
    expect(company.staffs).toEqual(staffs);
  });

  it('updateSalary() should update staff salary if staff exists', () => {
    expect(company.staffs[0].salary).toEqual(100);
    expect(company.staffs[1].salary).toEqual(50);

    const updateResult = company.updateSalary(1, 90);

    expect(company.staffs[0].salary).toEqual(100);
    expect(company.staffs[1].salary).toEqual(90);
    expect(updateResult).toEqual(company.staffs[1].name);
  });

+  it('updateSalary() should update staff salary if staff DOES NOT exist', () => {
+    expect(company.staffs[0].salary).toEqual(100);
+    expect(company.staffs[1].salary).toEqual(50);
+
+    const updateResult = company.updateSalary(2, 90);
+
+    expect(company.staffs[0].salary).toEqual(100);
+    expect(company.staffs[1].salary).toEqual(50);
+    expect(updateResult).toEqual('Staff not found');
+  });
});

Chạy test:

Randomized with seed 06626
Started
F..

Failures:
1) Company updateSalary() should update staff salary if staff DOES NOT exist
  Message:
    TypeError: Cannot set property 'salary' of undefined
  Stack:
    TypeError: Cannot set property 'salary' of undefined
        at Company.updateSalary (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.js:14:26)
        at UserContext.it (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.spec.js:35:34)
        at <Jasmine>
        at runCallback (timers.js:705:18)
        at tryOnImmediate (timers.js:676:5)

3 specs, 1 failure
Finished in 0.012 seconds
Randomized with seed 06626 (jasmine --random=true --seed=06626)

Fix lỗi runtime này và vẫn cố gắng để test spec này fail.

const lodash = require('lodash');

class Company {
  constructor(staffs) {
    this._staffs = staffs;
  }

  get staffs() {
    return lodash.cloneDeep(this._staffs);
  }

  updateSalary(staffId, salary) {
    const staffToUpdate = this._staffs.find(s => s.id === staffId);

+    if (!staffToUpdate) {
+      return;
+    }

    staffToUpdate.salary = salary;
    return staffToUpdate.name;
  }
}

exports.Company = Company;

Chạy lại test:

Randomized with seed 39798
Started
.F.

Failures:
1) Company updateSalary() should update staff salary if staff DOES NOT exist
  Message:
    Expected undefined to equal 'Staff not found'.
  Stack:
    Error: Expected undefined to equal 'Staff not found'.
        at <Jasmine>
        at UserContext.it (/Users/thinhdora/dev/thinhdora/article-tdd-can-ban-demo/company.spec.js:39:26)
        at <Jasmine>

3 specs, 1 failure
Finished in 0.011 seconds
Randomized with seed 39798 (jasmine --random=true --seed=39798)

Cuối cùng làm cho assertion pass:

const lodash = require('lodash');

class Company {
  constructor(staffs) {
    this._staffs = staffs;
  }

  get staffs() {
    return lodash.cloneDeep(this._staffs);
  }

  updateSalary(staffId, salary) {
    const staffToUpdate = this._staffs.find(s => s.id === staffId);

    if (!staffToUpdate) {
+      return 'Staff not found';
    }

    staffToUpdate.salary = salary;
    return staffToUpdate.name;
  }
}

exports.Company = Company;

Kết quả test sau cùng:

Randomized with seed 33716
Started
...

3 specs, 0 failures
Finished in 0.012 seconds
Randomized with seed 33716 (jasmine --random=true --seed=33716)

Chú ý quan trọng

Ở ví dụ trên, tại mỗi chu kì TDD, mình luôn nhấn mạnh chúng ta cần nhìn thấy assertion (các câu lệnh expect()) fail trước khi tiến hành code.

Tại sao?

Nếu bỏ qua giai đoạn sửa lỗi build-time và viết production code trực tiếp khi chưa chưa nhìn thấy assertion fail có thể khiến test spec của bạn viết ra có thể rơi vào trường hợp luôn đúng. Vì thế, việc làm cho test spec fail sẽ đảm bảo rằng test spec bạn vừa viết là đúng theo yêu cầu đề ra.

Arrange - Act - Assertion

Một test spec nên được phân chia rõ ràng theo pattern AAA (Arrange - Act - Assertion).

it('updateSalary() should update staff salary if staff exists', () => {
  // Arrange - Chuẩn bị dữ liệu test
  expect(company.staffs[0].salary).toEqual(100);
  expect(company.staffs[1].salary).toEqual(50);

  // Act - Hành vi kích hoạt test
  const updateResult = company.updateSalary(1, 90);

  // Assertion - Đối chiếu kết quả
  expect(company.staffs[0].salary).toEqual(100);
  expect(company.staffs[1].salary).toEqual(90);
  expect(updateResult).toEqual(company.staffs[1].name);
});

Các nguyên tắc TDD tiêu chuẩn

Nếu demo bên trên có thể đã mang lại cho các bạn cái nhìn thực tế nhất về TDD, thì phần này sẽ cho các bạn một góc nhìn đã được chuẩn hoá.

Các nguyên tắc / tiêu chuẩn này được đề cập trong cuốn sách Clean code. Mặc dù nội dung chính của cuốn sách nói về code sạch, tuy nhiên tại Chapter 9: Unit Tests (trang 121) có đề cập đến một số khái niệm rất căn bản giúp ích cho người mới tiếp cận TDD.

Nguyên tắc cơ bản của TDD

Trong chương Unit test, các bạn có thể tìm thấy một số nguyên tắc giúp bạn dễ dàng triển khai TDD hơn:

Đầu tiên là 3 nguyên tắc TDD (The Three Laws of TDD).

Ngay câu đầu tiên tác giả cũng nói luôn:

By now everyone knows that TDD asks us to write unit tests first, before we write production code. But that rule is just the tip of the iceberg.

Đại khái là thằng nào nghe TDD cũng biết là viết test trước rồi mới viết code, cơ mà nói dễ hơn làm. Nên cần hiểu và tuân thủ 3 nguyên tắc sau:

You may not write production code until you have written a failing unit test.

Chỉ được viết production code khi đã có test code (chắc chắn rồi), và (đây mới là cái quan trọng) test code phải failed. Một điều rõ ràng, chúng ta đang TDD, mà nếu test specs viết ra pass luôn thì chúng ta test gì chứ. Thêm nữa, fail ở đây nghĩa là chỉ các câu lệnh expectation trả về kết quả màu đỏ, không được phép có thêm báo lỗi màu đỏ nào từ compiler hoặc runtime.

You may not write more of a unit test than is sufficient to fail, and not compiling is failing.

Chỉ được viết đủ lượng test để tạo ra failed spec (nhấn mạnh lại, không được phép có thêm báo lỗi màu đỏ nào từ compiler hoặc runtime). Đại khái là chia thật nhỏ các dòng lệnh, các task ra để TDD.

You may not write more production code than is sufficient to pass the currently failing test.

Cuối cùng là không viết production code nhiều hơn test đã viết ra. Việc này để đảm bảo độ phủ (coverage) luôn là 100%. Nói thêm về 100% coverage. Đừng hiểu nhầm 100% coverage là cái gì đó an toàn tuyệt đối. 100% coverage chỉ có nghĩa là tất cả các đoạn code bạn viết ra đều đã được test ít nhất một lần, là bạn đã có sự chuẩn bị cho các rủi ro tiềm ẩn từ chính code của các bạn, hạn chế thấp nhất bug có thể xảy ra. Chỉ có càng tăng số lượng test cases thì rủi ro mới giảm mà thôi.

These three laws lock you into a cycle that is perhaps thirty seconds long. The tests and the production code are written together, with the tests just a few seconds ahead of the production code.

Bộ ba nguyên tắc này tạo ra một chu kì Test-Code ngắn, như ông tác giả nói thì là 30s (Thực tế có thể lâu hơn, tuy nhiên mục tiêu vẫn là tạo ra một vòng Test-Code càng ngắn càng tốt).

Test cũng phải sạch đẹp

Vẫn là Chapter 9: Unit Tests của Clean Code.

Tóm tắt một số điểm quan trọng:

Flexible, Maintainable, Reusable.

3 tính từ này thường được nhắc đến như là kết quả của một Design Pattern tốt. Tuy nhiên, design pattern tốt mới là điều kiện cần, điều kiện đủ để hiện thực hoá 3 tính từ trên (nhất là FlexibleMaintainable) chính là Test.. FlexibleMaintainable chính là khả năng thay đổi được code, để tự tin thay đổi production code, chúng ta cần Test tốt.

Readability - Tính dễ đọc hiểu

Đây là kim chỉ nam khi viết Test, và cũng luôn là kim chỉ nam khi lập trình!

5 quy tắc giúp giữ Test code sạch đẹp (F.I.R.S.T)

Một hay nhiều Assert cho mỗi Test spec?

Ý này bản thân tác giả cũng cho rằng một hay nhiều điều ok.

Thực tế từ bản thân mình cũng thấy tương tự và luôn có xu hướng nhiều assertion cho một test spec. Chắc chỉ có khi test pure function thì mới 1 assertion / 1 test spec.

Kết

Thực tế sẽ cần một khoảng thời gian không ngắn (có thể là vài tháng đến cả năm) để TDD nhuần nhuyễn, và thậm chí ngay cả khi đã nhuần nhuyễn, TDD vẫn sẽ có thể làm tăng chi phí dự án, tuy nhiên lợi ích lâu dài của TDD nói riêng và unit test nói chung là quá rõ ràng (các bạn có thể tìm đọc thêm trên internet). Những thứ đem lại lợi ích lâu dài thường sẽ tốn nhiều chi phí ban đầu.

Previous Post