Unit Test: Best practices

Tiki sử dụng mô hình microservices với số lượng service lên đến hàng trăm. Nếu sắp xếp thành các layer, có những service sẽ nằm ở layer trên cùng, ví dụ như những frontend service nhận request trực tiếp từ phía browser hay mobile app client. Những service ở giữa dạng consumer chuyên xử lý import, process dữ liệu lớn. Có những service nằm ở layer dưới dùng, ngay trước database. Những service ở layer dưới cùng ngoài việc chịu trách nhiệm đảm bảo ràng buộc chặt chẽ về mặt dữ liệu trước khi lưu vào database, nó còn phải chịu lượng request rất lớn đến từ nhiều service phía trên, thuộc về nhiều team. Vì thế 1 nhu cầu tối quan trọng của layer này là độ ổn định (bên cạnh nhiều yêu cầu khác như đáp ứng nhanh, tự động scale…). Cronus – Catalog Platform Writer là 1 service như vậy.

Với Cronus, mỗi một thay đổi về mặt logic, bổ sung tính năng, sửa lỗi, nhóm đều cần phải kiểm thử lại 1 lượt các logic hiện có. Việc kiểm thử này thường tốn rất nhiều effort của cả dev lẫn tester, nhất là thỉnh thoảng vẫn xuất hiện degrade, khiến team phải sửa đi sửa lại. Flow làm việc này thì không phù hợp với mô hình continuous delivery và continuous deployment hiện nay, khi mà các tính năng được release liên tục. Ngoài ra còn 1 vấn đề nan giải nữa mỗi khi team cần release 1 bản vá (hotfix), team thường phải cố gắng đưa bản hotfix lên càng sớm càng tốt đi kèm với nỗi lo lắng k biết rằng vá chỗ này có làm thủng chỗ khác hay không.

Giải pháp cho tất cả các vấn đề trên, đó là viết automation test, trong đó cụ thể team đang sử dụng cả Unit Test và Integration Test. Hiện tại Cronus có khoảng 800+ test case cho 45.000+ dòng code Java, code coverage vào khoảng 90%, đủ để nhóm tự tin với bất kỳ bản release nào.

1.  Testing in Cronus architecture

1.1. Unit vs Integration

Trước tiên cần làm rõ các loại test và điểm khác biệt giữa chúng, ở đây người viết chỉ tập trung vào 2 loại test được sử dụng.

1.1.1. Unit test

Unit test là test ở mức đơn vị (unit), đơn vị có thể là rất nhỏ, hoặc có thể lớn hơn. Đơn vị nhỏ nhất là function. Các class, interface, usecase cũng có thể coi là 1 đơn vị.

Unit test chỉ test các logic được define bởi đơn vị đó, mà không bao gồm các dependency khác (như database, external service). Unit test có thể chạy độc lập mà ko cần setup test environment.

Các dependency của Unit test thường được dùng Test-Double (xem thêm bên dưới) để xử lý thay vì sử dụng đối tượng thật.

Do bản chất Unit Test không quan tâm đến các logic khác, cho nên nó cũng ko thể đảm bảo bug-free cho application. Unit test giúp đảm bảo độ ổn định cho từng unit và cung cấp 1 live document về cách thức mà unit này hoạt động (các developer thay vì phải đọc code của các component để xem chúng làm gì, các test case được viết 1 cách rõ ràng của Unit Test có thể làm điều đó tốt hơn, hơn nữa đọc code của developer khác thường là trải nghiệm ko mấy vui vẻ).

1.1.2. Integration test

Integration bù đắp phần còn thiếu cho Unit Test: nó sẽ kiểm tra sự tương tác giữa các component với nhau. Ở Cronus, nhóm thường test sự tương tác với cả các external dependency như database hoặc external service, việc này giúp cho code test được đơn giản và chính xác hơn so với khi chạy thực tế.

Các dependency của Integration test sẽ phải được set up trước khi chạy, để đảm bảo code được thực thi y như môi trường thật (Ví dụ như phải dựng 1 database server và load data vào trong database).

Integration giúp ta catch được các lỗi cơ bản trong 1 flow trước khi đưa nó đến tay tester. Integration sẽ không quan tâm các component hoạt động như nào mà chỉ cần confirm lại kết quả cuối cùng có đúng không. Trong khi Unit Test quan tâm đến detail thì Integration Test hỏi về kết quả.

1.2. Unit + Integration test in Cronus architecture

Để viết được test code thì project cũng phải đảm bảo được những điều kiện khắt khe về architecture. Ở Cronus nhóm đã tham khảo nhiều kiến trúc khác nhau như clean architecture, hexagonal… để dựng lên 1 mô hình phù hợp nhất và thỏa mãn các yêu cầu của team.

Unit Test sẽ được viết ở các layer có chứa business logic (tương ứng với Usecases layer và Entities layer trong Clean Architecture). Còn đối với layer application hoặc layer infrastructure thì sử dụng Integration Test ( tương ứng với Controllers/Gateways layer trong Clean Architecture).

Cronus architecture

2. How to write good Unit tests

Unit Test được coi là tốt nếu đảm bảo các yêu cầu sau: thực thi nhanh, gọn gàng, dễ đọc, dễ maintain và thực sự hữu ích (hữu ích ở đây là giúp ta phát hiện lỗi sớm và có thể dùng làm live document cho project). Team đã sử dụng các best practise sau:

2.1. Choose the right test strategy

2.1.1. State testing

Có thể áp dụng cho các component có nhiệm vụ thay đổi state của application. Với các component này ta thực hiện các action, sau đó verify lại state sau khi thay đổi nhằm đảm bảo được component hoạt động đúng.

Ưu điểm: More resilient to changes, việc thay đổi implementation detail không làm thay đổi kết quả test.

2.1.2. Interaction testing

Đúng như tên gọi, ta test 1 component thông qua các tương tác của nó với các dependency, thường sử dụng với các component có side effect.

Chẳng hạn 1 command có nhiệm vụ update vào database và return kết quả success/fail, với trường hợp này ta chỉ cần verify database nhận được 1 request update với đúng các input đã dùng. Nói cách khác, Interaction test focus vào hành vi (requirements/results/behaviours), hoàn toàn bỏ qua cách thức thực hiện (implement detail).

Ưu điểm: More resilient to changes, việc thay đổi implementation detail không làm thay đổi kết quả test.

2.1.3. White-box testing

Thỉnh thoảng, ta cần viết test dựa trên implementatin detail. Việc này thường yêu cầu ta hiểu rõ source code để viết test và nhắm tới coverage 100% code branches.

Test cần cover được tất cả các execution branches trong source code, đảm bảo tất cả các dòng đều đã được verify ít nhất 1 lần, và thưởng xuyên kiểm tra code coverage để đảm bảo điều đó.

Ưu điểm: Đảm bảo tính chính xác của từng dòng code.

Nhược điểm: việc thay đổi implementation detail làm thay đổi kết quả test.

2.2. AAA Pattern

Để giúp test code dễ đọc hơn, Cronus áp dụng AAA pattern, đó là chia code test thành 3 step: Arrange, Act, and Assert.

class CalculatorTest {
    @Test
    void sum_of_two_numbers() {
        // Arrange 
        int first = 2;
        int second = 5;
        var calculator = new Calculator();

        // Act
        int result = calculator.sum(first, second);

        // Assert
        assertEquals(7, result);
    }
}

Còn nếu bạn là fan của BDD style (Behavior-driven development) thì có thể thay thế bằng 3 step: Given, When, Then cũng OK.

2.3. Naming convention

Test được dùng để mô tả behavior của component, vì vậy khi đặt tên cần làm rõ được behavior tương ứng, test case nên đọc được như 1 spec:

  • Bad: testPaymentFailtestPaymentSuccess
  • Good: payment_should_fail_if_account_insufficient_fundspayment_should_fail_if_transaction_high_risk

Với Cronus, nhóm sử dụng cú pháp underscores để viết tên function, sau đó đặt config sau vào src/test/resources/junit-platform.properties:

junit.jupiter.testmethod.order.default = org.junit.jupiter.api.MethodOrderer$Alphanumeric
junit.jupiter.displayname.generator.default = org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores

Khi đó test sẽ được hiển thị như sau trên IDE:

2.4. DRY test, how?

Dưới đây là 1 số kỹ thuật để làm test code gọn gàng, dễ quản lý và maintain:

2.4.1. Parameterized Tests

Với 1 số function có logic tính toán phức tạp, ta thường phải test cùng 1 kịch bản với nhiều input khác nhau, và đảm bảo rằng từng output là phải chính xác. Parameterized test cung cấp 1 phương thức để define nhiều input và output tương ứng cho 1 kịch bản duy nhất, mỗi bộ input/output được coi là 1 source

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
    assertTrue(StringUtils.isPalindrome(candidate));
}

Source trong JUnit 5 có thể là 1 list các value (ValueSource), list enums (EnumSource), CSV (CsvSource, CsvFileSource), hoặc dùng 1 method để cung cấp data (MethodSource).

Xem thêm tại: https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests

Dưới đây là function test import data với đầu vào là csv file với rất nhiều input file khác nhau đều trả về kết quả validate fail

@ParameterizedTest(name = "TC {index}: input file ''{0}'' fail with error code {1}")
@CsvSource({
        "import_don_tiki_trading_missing_required_attribute.json, 2548",
        "import_don_tiki_trading_wrong_supplier.json, 2492",
        "import_don_tiki_trading_wrong_po_type.json, 2601",
        "import_don_tiki_trading_missing_supplier_id.json, 2495",
        "import_don_tiki_trading_missing_po_type.json, 2602",
        "import_don_tiki_trading_missing_vat.json, 2548",
        "import_don_tiki_trading_missing_dimension.json, 2548",
        "import_don_with_invalid_seller_warehouse.json, 4203",
        "import_don_duplicate_product_code.json, 2472"
})
public void should_update_import_item_to_fail_when_data_is_not_valid(String rawDataFileSource, int errorCode) throws Exception {
    var input = ProcessMasterItemCreatedUseCase.MasterItemData.builder()
            .rawDataList(TestRawDataUtils.readListRawData("../data/raws/" + rawDataFileSource))
            .importId(IMPORT_ID_OF_TIKI_STAFF)
            .build();

    // When
    var result = useCase.process(input).get();

    // Then
    assertFalse(result);
    verifyImportRepositoryUpdateImportItemStatusWithSimpleProduct(errorCode);
}

Kết quả:

2.4.2. Shared methods

Đơn giản nhưng không kém phần quan trọng: nếu bạn thấy mình đang copy/paste 1 đoạn test nào đó, hãy dừng ngay lại. Việc extract code ra các method ko chỉ làm phần code test của ta dễ đọc hơn mà còn có thể reuse.

2.5. Test-Double

Đôi khi ta sẽ gặp khó khăn khi chạy test trên các đối tượng mà có sự phụ thuộc vào các dependency khác ở bên trong lẫn bên ngoài hệ thống. Unit test ko khuyến khích sử dụng các đối tượng thật (real component) mà chỉ nên focus vào logic ta cần test, việc phụ thuộc vào các đối tượng khác sẽ tăng khả năng fail và làm unit test chạy không ổn định.

Test-double là khái niệm về việc thay thế các real component bằng các cascadeur (thế thân).

2.5.1. Fake

Fakes là các object có thể hoạt động được giống như các real dependency, nhưng thường theo các thức đơn giản hơn (Ví dụ như 1 in-memory storage thay vì 1 database thật sự).

Fake được sử dụng khi ta muốn cho component của ta hoạt động được, bỏ qua sự phụ thuộc vào các dependency, nói cách khác là To make it through.

public class FakeAuthService implements AuthService {
    /**
     * Check given user is authenticated or not?
     */
    public boolean isAuthenticated(User user) {
        if (user.isActive()) {
            return true; // fake logic: always return true if user is active
        }

        return false;
    }
}

2.5.2. Stub

Stub là 1 object có chứa predefined data và có thể dùng để handle các request trong quá trình chạy test.

Việc chuẩn bị trước data giúp ta verify được độ chính xác của kết quả test.

class ShippingWeightValidatorAsyncTest {
    private ShippingWeightValidatorAsync validator;
    private QueryCategoryRepository categoryRepository;

    @BeforeEach
    public void beforeEach() {
        categoryRepository = mock(QueryCategoryRepository.class); // đây là 1 mock/stub object
        validator = new ShippingWeightValidatorAsync(categoryRepository);
    }

    @Test
    void should_fail_when_shipping_weight_valid() {
        // Arrange: define data được trả về khi cần query category để thực hiện validate
        var categoryEntity = CatalogCategoryEntity.builder()
                .categoryId(100)
                .minShippingWeight(1)
                .maxShippingWeight(99)
                .build();
        when(categoryRepository.findByCategoryId(100)).thenReturn(categoryEntity);

        var product = new ProductEntity();
        product.setCategoryId(100);

        // Act
        var result = validator.validateAsync(product).get();

        // Assert
        assertFalse(result.isValid());
    }
}

2.5.3. Mock

Mock là đối tượng giúp ta verify được hành vi của component, nhất là với các đối tượng có side effect (thực hiện call tới db/external service để update dữ liệu). Ta có thể liệt kê các interaction mong chờ sẽ được gọi, kiểm tra lại số lần gọi + với validate input đầu vào của chúng.

Mock được dùng để kiểm tra hành vi (behavior) của component.

public class SellerServiceTest {
    private CacheService cacheService;
    private SellerService sellerService;

    @BeforeEach
    public void beforeEach() {
        cacheService = mock(CacheService.class);
        sellerService = new SellerService(cacheService);
    }

    @Test
    public void query_seller_should_use_cache() {
        // Act
        sellerService.getSellerById(1);

        // Assert
        // xác nhận rằng seller service lấy dữ liệu từ cache trước khi thực hiện truy vấn database
        verify(cacheService).get("seller_1");
        var seller = SellerInfo.builder().id(1).name("seller 1").build();
        // xác nhận rằng seller service update ngược dữ liệu vào cache sau khi truy vấn database
        verify(cacheService).put("seller_1", seller);
    }
}

2.6. Setup dependency on integration testing

2.6.1. Prepare database

Integration Test yêu cầu ta phải chuẩn bị trước các dependency, trong đó có database. Có khá nhiều cách để thực hiện việc này như: cài đặt 1 local database, dùng in-memory database (1 dạng database lưu trữ trên memory và có interface tương ứng với các loại db khác, VD như h2 tương ứng với MySQL), embedded database hoặc docker container.

Do sự phức tạp của việc cài đặt local database, còn in-memory có thể không tương thích hoàn toàn với API của db gốc, Cronus đã sử dụng embedded database, với API hoàn toàn tương thích với db gốc (là MySQL). Embedded database được lựa chọn là https://github.com/wix/wix-embedded-mysql

public static void initEmbeddedMysql() {
    if (mysqld != null) {
        return;
    }

    MysqldConfig config = MysqldConfig.aMysqldConfig(Version.v5_7_latest)
            .withPort(3309)
            .withUser("unit_test", "12345678")
            .withTimeZone("Asia/Ho_Chi_Minh")
            .build();

    mysqld = EmbeddedMysql.anEmbeddedMysql(config)
            .addSchema("catalogdb", ScriptResolver.classPathScript("v3.sql"))
            .start();
}

Lựa chọn thứ 2 cho database mà nhóm khuyến khích là testcontainers. Testcontainers sử dụng Docker engine để dựng lên các container cần thiết. Testcontainer có ưu điểm là dễ dàng set up, hỗ trợ nhiều loại db (MySQL, Postgres) và queue (Kafka), bù lại bạn cần chuẩn bị Docker engine ở môi trường local cũng như trên build server.

@Testcontainers
public class IntegrationTest {
    @Container
    protected static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:5.7")
            .withDatabaseName("catalog_db")
            .withUsername("root")
            .withPassword("12345678")
            .withConfigurationOverride("conf.d")
            .withCopyFileToContainer(MountableFile.forClasspathResource("db/schema.sql"), "/docker-entrypoint-initdb.d/schema.sql");

    @Container
    protected static final KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:6.2.1"));
}

2.7. Code coverage: more is less?

Thói quen của mình sau khi viết test thường là sẽ check lại code coverage xem đã được bao nhiêu %, và liệu còn có method nào, branch nào trong component còn chưa được test đến hay không.

Nhóm luôn đặt mục tiêu là code coverage > 80% và đồng thời các branch quan trọng sẽ được chạy qua ít nhất 1 lần

2.8. One test – One Assertion: is that good?

Cuối cùng, nhưng lại vô cùng quan trọng: assertions. Người viết bài này thường thấy có nhiều file test gần như không có assertions gì, miễn chạy không có error, exception là oke, có nhiều file test thì assert rất nhiều trong cùng 1 test case. 2 cách trên thành thực mà nói đều không tốt.

  • Quá ít hoặc không có assertions: ta sẽ không thể đảm bảo được component chạy đúng logic, chỉ có thể đảm bảo ko có lỗi exception xảy ra.
  • Quá nhiều assertions: Unit test sẽ không còn là 1 live document nữa, vì 1 test case của ta đang assert rất nhiều thứ, liệu có thể đặt tên mô tả được toàn bộ nội dung cho 1 test case như vậy?

Hướng đi mà nhóm suggest là: Giống như khi viết 1 method, ta nên chia nhỏ test case ra hết mức có thể, 1 test case sẽ nhằm mục đích cover 1 và chỉ 1 spec/behavior, và assertions là bắt buộc để verify lại spec/behavior đó có hoạt động đúng hay không. Tuy nhiên không cứng nhắc là 1 test case chỉ nên dùng 1 assertions. Test case có thể có nhiều assertion, miễn là ta đang assert cho chung 1 spec/behavior.

Not good

@Test
void test_update_success() throws Exception {
    // when update success
    // Assert change updated by and version
    // Assert trigger event store events
    // Assert trigger event source events
    // Assert value changed and saved to database
}

Better

@Test
void should_change_updated_by_and_version_when_update_success() {
}

@Test
void should_trigger_event_store_events_when_update_success() {
}

@Test
void should_trigger_event_source_events_when_update_success() {
}

@Test
void should_change_attribute_value_and_save_to_db_when_update_success() {
}

3. Integrate Unit test with CI/CD (Maven + Jenkins)

  • Đầu tiên, cần đảm bảo project đã sử dụng phiên bản JUnit mới nhất là JUnit 5 (hiện tại người viết bài này chưa biết cách chạy test chung cho cả version 4 và 5 trên maven nên suggest chỉ chọn 1 mà thôi)
  • Update file pom.xml để config maven-surefire-plugin giúp chạy test bằng maven với command: maven test
<build>
    <plugins>
        <plugin>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.0.0-M1</version>
            <configuration>
                <excludes>
                    <exclude>**/vn/tiki/cronus/infra/mysql/**/*.java</exclude>
                    <exclude>**/vn/tiki/cronus/infra/http/**/*.java</exclude>
                </excludes>
             </configuration>
        </plugin>
    </plugins>
</build>
  • Lưu ý nên sử dụng excludes path để loại bỏ các integration testing (vì trong môi trường docker chạy test trên build server thường sẽ không thể kết nối được tới các service khác, thực tế người viết bài cũng chỉ khuyến khích chạy Unit Test trong CI/CD để đảm bảo độ ổn định).
  • Lúc này bạn có thể thử chạy lệnh maven test trên máy local để đảm bảo unit test đã chạy ổn.
  • Tiếp theo cần config ci/cd, cái này thì tùy từng công ty, từng project sẽ có cách config khác nhau:
jobs:
  unittest:
    docker:
      - image:
          name: '{{ .env.BUILD_IMAGE }}'
    steps:
      - run:
          name: 'Test command'
          command: 'mvn test'
  build:
    ......
workflows:
  jenkins_pipeline:
    jobs:
      - unittest
      - build:
          requires:
            - unittest
      - build_docker:
          ......
  • Giờ đây khi build và deploy project với TikiCi ta sẽ có thêm step unit test, hệ thống sẽ từ chối build và deploy nếu có 1 test case bị fail
CI pipeline results

Please follow and like us:

Leave a Reply

Your email address will not be published. Required fields are marked *