TDD là gì, code ít bug hơn với TDD

338

I. Testing là gì

Trong phạm trù “phần mềm học” thì testing là một bước trong quy trình phát triển phần mềm. Mục đích của testing là đảm bảo phần mềm chạy đúng theo yêu cầu.

Tùy vào từng quy trình phát triển phần mềm mà testing có thể xuất hiện ở các bước khác nhau. Như trong quy trình phát triển phần mềm “Thác nước”, thì testing (kiểm thử) nằm ở bước thứ 4.

Các bước trong quy trình phát triển phần mềm thác nước

Có thể bạn chưa biết

Testing thường được thực hiện sau khi đã có chương trình (sau khi code), nên khiến cho nhiều developer coi nhẹ bước này vì cho rằng “tại sao lại phải test một đoạn code đã chạy đúng”. Vì thế mà rất nhiều phần mềm được tạo ra mà không hề có bước testing.

Testing có thể thực hiện bằng sức người, tức là con người sẽ trực tiếp thao tác trên phần mềm để tìm lỗi. Hoặc cũng có thể thực hiện qua các “công cụ test tự động”, người dùng sẽ đưa ra các “kịch bản test” kèm kết quả mong muốn, nó sẽ chạy và so sánh kết quả thực tế và kết quả mong muốn để phát hiện lỗi.

II. Testing Driven Development

2.1 TDD là gì?

TDD (Testing Driven Development – Phát triển hướng kiểm thử) là một quy trình phát triển phần mềm mà bước kiểm thử được thực hiện trước bước phát triển (tức là test trước khi code).

Ấy mà khoan, chưa code thì lấy cái gì để mà test nhỉ 🤔 🤔 🤔 – Đây cũng chính là điểm khác biệt của TDD với quy trình phát triển phần mềm truyền thống. Bạn cứ đọc hết bài viết sẽ rõ.

2.2 TDD được thực hiện như thế nào?

TDD được thực hiện qua các bước sau:

  • Bước 1: Viết một “kịch bản test” (test case), và đảm bảo nó sẽ fail (vì chưa code gì cả, fail là chắc)
  • Bước 2: Code để pass qua test case đó
  • Bước 3: Bổ sung thêm test case mới
  • Bước 4: Tiếp tục code để pass qua test case mới
  • Bước 5: Lặp đi lặp lại các bước tương tự 3 – 4 cho đến khi nào pass hết các test case
  • Bước 6: Refactor code – điều chỉnh lại code cho gọn gàng, dễ hiểu, để người sau vào đọc đỡ chửi

Bạn sẽ hiểu rõ hơn khi chúng ta thực hiện ví dụ qua phần III.

III. Ví dụ về TDD

Lý thuyết vậy là đủ, giờ chúng ta sẽ thực hiện một ví dụ đơn giản theo quy trình TDD để hiểu rõ hơn về nó.

Bài toán

Viết một function truyền vào một số nguyên N và trả về:
– Trả về “Fizz” nếu N chia hết cho 3
– Trả về “Buzz” nếu N chia hết cho 5
– Trả về “FizzBuzz” nếu N chia hết cho cả 3 và 5
– Trả về N nếu là các trường hợp còn lại

Mình sẽ sử dụng PHP để triển khai bài toán trên, và sử dụng PHPUnit để làm công cụ test tự động.

Bước 1: Setup các thứ

Tạo thư mục làm việc và cài đặt phpunit.

mkdir tdd && cd tdd && mkdir src && mkdir tests
composer require --dev phpunit/phpunit ^9

Chỉnh sửa file composer.json cho giống như sau:

{
    "autoload": {
        "classmap": [
            "src/"
        ]
    },
    "require-dev": {
        "phpunit/phpunit": "^9"
    }
}

Chạy autoload

composer dump-autoload

Tạo file src/FizzBuzz.php với nội dung như sau:

<?php

class FizzBuzz
{
    // Đây là function sẽ xử lý yêu cầu đề bài
    // $number chính là số N mà đề bài cho
    public static function run($number)
    {
        // lát nữa sẽ bổ sung code ở đây
    }
}

Tạo file tests/FizzBuzzTest.php với nội dung như sau:

<?php

use PHPUnit\Framework\TestCase;

final class FizzBuzzTest extends TestCase
{
    // Các test case sẽ dần dần được bổ sung ở đây
}

Bước 2: Triển khai TDD

Chúng ta đã có đầy đủ “nguyên liệu”, giờ sẽ bắt đầu triển khai TDD.

Bước 1: Tạo test case đầu tiên và đảm bảo nó fail

Mình sẽ tạo test case cho trường hợp trả về chữ Fizz, nếu N chia hết cho 3 trước.

<?php

use PHPUnit\Framework\TestCase;

final class FizzBuzzTest extends TestCase
{
    // Test case đầu tiên
    // Test xem có trả về chữ "Fizz" hay không
    public function testFizz()
    {
        $expect = 'Fizz'; // Kết quả mong muốn là chữ 'Fizz'
        $actual = FizzBuzz::run(3); // Kết quả thực tế trả về qua hàm run()

        // So sánh kết quả thực tế và kết quả mong muốn
        $this->assertEquals($actual, $expect);
    }

}

Chạy thử bằng lệnh

./vendor/bin/phpunit tests

Và kết quả là:

PHPUnit 9.3.9 by Sebastian Bergmann and contributors.
F 1 / 1 (100%)
Time: 00:00.026, Memory: 4.00 MB
There was 1 failure:
1) FizzBuzzTest::testFizz
Failed asserting that 'Fizz' matches expected null.
/home/vagrant/www/pets/phpunit/tests/FizzBuzzTest.php:13
FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

Bạn thấy đó, nó sẽ báo Failures: 1, tức là có 1 test case bị fail. Đương nhiên, vì mình chưa code gì mà.

Bước 2: Code để pass test case đầu tiên

Chúng ta sẽ sửa lại code trong file src/FizzBuzz.php như sau:

<?php

class FizzBuzz
{
    public static function run($number)
    {
        // Trả về Fizz để pass test case đầu tiên
        return 'Fizz';
    }
}

Bằng cách đơn giản nhất, mình chỉ cần return 'Fizz' thì chắc chắn là sẽ pass. Nhưng cứ chạy lại test cho chắc.

./vendor/bin/phpunit tests

Và kết quả là:

PHPUnit 9.3.9 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.021, Memory: 4.00 MB
OK (1 test, 1 assertion)

Kết quả trên nghĩa là mọi test case đã pass.

Bước 3: Bổ sung thêm test case mới

Mình sẽ tạo tiếp test case cho trường hợp trả về Buzz nếu N chia hết cho 5.

Bổ sung thêm test case trong file tests/FizzBuzzTest.php như sau:

<?php

use PHPUnit\Framework\TestCase;

final class FizzBuzzTest extends TestCase
{
    //...

    public function testBuzz()
    {
        $expect = 'Buzz'; // Kết quả mong muốn
        $actual = FizzBuzz::run(5); // Kết quả thực tế

        // So sánh kết quả thực tế và kết quả mong muốn
        $this->assertEquals($actual, $expect);
    }
}

Chạy thử

./vendor/bin/phpunit tests

Và kết quả là:

PHPUnit 9.3.9 by Sebastian Bergmann and contributors.
.F 2 / 2 (100%)
Time: 00:00.019, Memory: 4.00 MB
There was 1 failure:
1) FizzBuzzTest::testBuzz
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'Fizz'
+'Buzz'
/home/vagrant/www/pets/phpunit/tests/FizzBuzzTest.php:22
FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

Nó báo là test case testBuzz bị sai, kết quả mong muốn là Buzz nhưng kết quả thực tế lại là Fizz. Đương nhiên, vì hàm FizzBuzz::run() luôn trả về chữ Fizz mà.

Bước 4: Tiếp tục code để pass qua test case mới

Chúng ta sẽ sửa lại code trong file src/FizzBuzz.php như sau:

<?php

class FizzBuzz
{
    public static function run($number)
    {
        // Chia hết cho 3 thì trả về chữ Fizz
        if ($number % 3 == 0) {
            return 'Fizz';
        }

        // Chia hết cho 5 thì trả về chữ Buzz
        if ($number % 5 == 0) {
            return 'Buzz';
        }
    }
}

Chạy lại xem kết quả thế nào

./vendor/bin/phpunit tests

Và kết quả là:

PHPUnit 9.3.9 by Sebastian Bergmann and contributors.
.. 2 / 2 (100%)
Time: 00:00.014, Memory: 4.00 MB
OK (2 tests, 2 assertions)

Vậy là tất cả test case đã pass.

Bước 5: Lặp đi lặp lại các bước tương tự 3 – 4

Chúng ta còn 2 trường hợp nữa là:

– Trả về FizzBuzz khi N chia hết cho cả 3 và 5
– Trả về N nếu là các trường hợp còn lại

Chúng ta sẽ lần lượt triển khai thêm 2 trường hợp này.

Trả về FizzBuzz khi N chia hết cho cả 3 và 5

Bổ sung thêm test case trong file tests/FizzBuzzTest.php như sau:

<?php

use PHPUnit\Framework\TestCase;

final class FizzBuzzTest extends TestCase
{
    //...

    public function testFizzBuzz()
    {
        $expect = 'FizzBuzz'; // Kết quả mong muốn
        $actual = FizzBuzz::run(15); // Kết quả thực tế

        // So sánh kết quả thực tế và kết quả mong muốn
        $this->assertEquals($actual, $expect);
    }
}

Kết quả chạy test sau khi có test case mới:

PHPUnit 9.3.9 by Sebastian Bergmann and contributors.
..F 3 / 3 (100%)
Time: 00:00.028, Memory: 4.00 MB
There was 1 failure:
1) FizzBuzzTest::testFizzBuzz
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'Fizz'
+'FizzBuzz'
/home/vagrant/www/pets/phpunit/tests/FizzBuzzTest.php:31
FAILURES!
Tests: 3, Assertions: 3, Failures: 1.

Kết quả là fail và fail ở test case testFizzBuzz.

Sửa lại code trong file src/FizzBuzz.php như sau:

<?php

class FizzBuzz
{
    public static function run($number)
    {
        // Nếu chia hết cho cả 3 và 5 thì trả về chữ FizzBuzz
        // Trường hợp này cần phải viết lên trước 2 trường hợp
        // chỉ chia hết cho 3 và chỉ chia hết cho 5
        if ($number % 3 == 0 &amp;&amp; $number % 5 == 0 ) {
            return 'FizzBuzz';
        }

        // Chia hết cho 3 thì trả về chữ Fizz
        if ($number % 3 == 0) {
            return 'Fizz';
        }

        // Chia hết cho 5 thì trả về chữ Buzz
        if ($number % 5 == 0) {
            return 'Buzz';
        }
    }
}

Kết quả chạy lại sau khi sửa code là:

PHPUnit 9.3.9 by Sebastian Bergmann and contributors.
… 3 / 3 (100%)
Time: 00:00.014, Memory: 4.00 MB
OK (3 tests, 3 assertions)

OK, vậy là đã pass.

Trả về N nếu là các trường hợp còn lại

Tiếp tục bổ sung thêm test case:

<?php

use PHPUnit\Framework\TestCase;

final class FizzBuzzTest extends TestCase
{
    //...

    public function testOthers()
    {
        $expect = 11; // Kết quả mong muốn
        $actual = FizzBuzz::run(11); // Kết quả thực tế

        // So sánh kết quả thực tế và kết quả mong muốn
        $this->assertEquals($actual, $expect);
    }
}

Kết quả chạy test sau khi có test case mới

PHPUnit 9.3.9 by Sebastian Bergmann and contributors.
…F 4 / 4 (100%)
Time: 00:00.026, Memory: 4.00 MB
There was 1 failure:
1) FizzBuzzTest::testOthers
Failed asserting that 11 matches expected null.
/home/vagrant/www/pets/phpunit/tests/FizzBuzzTest.php:40
FAILURES!
Tests: 4, Assertions: 4, Failures: 1.

Kết quả là fail và fail ở test case testOthers.

Sửa lại code trong file src/FizzBuzz.php như sau:

<?php

class FizzBuzz
{
    public static function run($number)
    {
        // Nếu chia hết cho cả 3 và 5 thì trả về chữ FizzBuzz
        // Trường hợp này cần phải viết lên trước 2 trường hợp
        // chỉ chia hết cho 3 và chỉ chia hết cho 5
        if ($number % 3 == 0 &amp;&amp; $number % 5 == 0 ) {
            return 'FizzBuzz';
        }

        // Chia hết cho 3 thì trả về chữ Fizz
        if ($number % 3 == 0) {
            return 'Fizz';
        }

        // Chia hết cho 5 thì trả về chữ Buzz
        if ($number % 5 == 0) {
            return 'Buzz';
        }

        // Trả về $number trong các trường hợp còn lại
        return $number;
    }
}

Kết quả test sau khi sửa code:

PHPUnit 9.3.9 by Sebastian Bergmann and contributors.
…. 4 / 4 (100%)
Time: 00:00.020, Memory: 4.00 MB
OK (4 tests, 4 assertions)

Vậy là đã pass tất cả các test case.

Bước 6: Refactor code

Refactor code là chỉnh lại code sao cho gọn gàng, dễ hiểu, vì trong quá trình bổ sung thêm code mới có thể làm code tổng thể trở nên rối rắm, khó hiểu. Nhưng vì ví dụ này quá đơn giản nên không có gì để refactor cả.

Lưu ý

Một số developer cho rằng “Code đang chạy thì không nên sửa”, nhưng với trường hợp này bạn đã có test case, và bạn chỉ cần đảm bảo sau khi sửa code vẫn pass hết các test case là được.

Bộ sưu tập áo thun cho dân IT, đủ các ngôn ngữ lập trình và hệ điều hành.
Click vào ảnh để xem.
QC Được tài trợ

IV. Tổng kết

TDD đơn giản là viết test case trước và implement code sau, nghe qua tưởng “dở”, thế nhưng khi áp dụng thì lại thấy hay, bởi:

  • TDD tạo động lực cho developer viết test case, sẽ không còn suy nghĩ “Tại sao phải test một đoạn code đã chạy” khi mà bạn còn chưa code một dòng nào.
  • Bạn sẽ “thong thả” mà sửa code, không sợ sửa chỗ này “đá” chỗ kia, vì đã có test case đảm bảo.
  • Viết test trước khi code giúp bạn tập trung suy nghĩ vào kết quả đạt được hơn là cách triển khai, điều này giúp bạn có cái nhìn tổng quát về chương trình.

Một số lưu ý khi áp dụng TDD:

  • Không viết thì thôi, nếu viết test case thì phải bao hết các trường hợp, nếu không sẽ phản tác dụng.
  • Mục đích sau cùng vẫn là phần mềm chạy tốt, nên hãy ưu tiên TDD cho những tính năng quan trọng.
  • Test case cũng là code, vì thế bạn cũng phải dành thời gian để maintain code ở các test case nữa.
  • Đừng áp dụng TDD khi sản phẩm của bạn “quá đơn giản”, nhưng lưu ý, không áp dụng TDD chứ không phải là không có bước testing.

Sau cùng, liệu bạn có áp dụng TDD trong dự án tiếp theo?