0

Your AI-Written Tests Are Too Polite: Mutation Testing and Property-Based Testing to Catch Slop

Dạo này mình review PR thì thấy một kiểu rất quen: code do AI viết, test cũng do AI viết, coverage báo 95%, CI xanh. Nhìn thì yên tâm, nhưng deploy xong vẫn dính bug ở những case "hiển nhiên". Lý do khá đơn giản: khi cùng một model viết cả code lẫn test, test thường chỉ xác nhận lại những gì code đang làm chứ không thật sự kiểm tra code có đúng không. Test kiểu đó quá "lịch sự", không chịu cãi lại code. Trên Dev.to gần đây có bài "AI Needs a Test That Fights Back", còn trên HN thì người ta đang bàn chuyện dùng AI tốt cũng là một kỹ năng. Mình đồng ý với cả hai. Trong bài này mình chia sẻ hai kỹ thuật mình đang dùng thật để bắt test phải "đánh lại": mutation testing và property-based testing.

Vấn đề: coverage cao không có nghĩa là test tốt

Coverage chỉ cho biết dòng code nào đã chạy, không cho biết test có assert đúng thứ cần assert hay không. Lấy một ví dụ thực tế:

# src/pricing.py
def apply_discount(price: int, percent: int) -> int:
    if percent < 0 or percent > 100:
            raise ValueError("percent must be 0-100")
                return price - price * percent // 100
                ```
                
                Đây là bộ test mà AI sinh ra cho hàm trên, khá đúng kiểu thường gặp:
                
                ```python
                # tests/test_pricing.py
                import pytest
                from src.pricing import apply_discount
                
                def test_apply_discount():
                    assert apply_discount(100, 10) == 90
                    
                    def test_invalid_percent():
                        with pytest.raises(ValueError):
                                apply_discount(100, 150)
                                ```
                                
                                Coverage đạt 100%. Thử nghĩ xem chuyện gì xảy ra nếu ai đó vô tình đổi `percent > 100` thành `percent >= 100`? Hoặc đổi `//` thành `/`? Với `/` thì `apply_discount(100, 10)` trả về `90.0`, mà `90.0 == 90` trong Python vẫn là `True`. Cả hai bug này đều **lọt qua** bộ test. Kiểu test này chỉ tạo cảm giác an toàn chứ không bảo vệ được gì.
                                
                                ## Mutation testing: cố tình phá code xem test có phát hiện không
                                
                                Ý tưởng của mutation testing: tool tự sinh ra hàng loạt phiên bản "đột biến" (mutant) của code, ví dụ đổi `>` thành `>=`, `+` thành `-`, `True` thành `False`, rồi chạy lại test suite với từng mutant. Mutant nào làm test fail là bị "killed" (tốt). Mutant nào test vẫn pass là "survived", tức là test của bạn có lỗ hổng ở đúng chỗ đó.
                                
                                ```mermaid
                                flowchart LR
                                    A[Source code] --> B[Mutation tool]
                                        B --> C[Mutant 1: > thành >=]
                                            B --> D[Mutant 2: // thành /]
                                                C --> E{Chạy test suite}
                                                    D --> E
                                                        E -->|Test fail| F[Killed ✅]
                                                            E -->|Test pass| G[Survived ❌]
                                                                G --> H[Viết thêm test / sửa assert]
                                                                    H --> A
                                                                    ```
                                                                    
                                                                    Với Python, mình dùng **mutmut 3.x**. Cách setup:
                                                                    
                                                                    ```bash
                                                                    pip install "mutmut>=3.0" pytest hypothesis
                                                                    
                                                                    # pyproject.toml
                                                                    cat >> pyproject.toml <<'EOF'
                                                                    [tool.mutmut]
                                                                    paths_to_mutate = ["src/"]
                                                                    tests_dir = ["tests/"]
                                                                    EOF
                                                                    
                                                                    mutmut run          # sinh mutant và chạy test
                                                                    mutmut results      # liệt kê các mutant survived
                                                                    mutmut show src.pricing.x_apply_discount__mutmut_3   # xem diff của 1 mutant
                                                                    ```
                                                                    
                                                                    Chạy với bộ test ở trên, mutmut sẽ báo vài mutant survived, trong đó có đúng hai mutant `>=` và `/` mình vừa nói. Đây là danh sách việc cần làm cụ thể, không phải con số coverage chung chung.
                                                                    
                                                                    Với JavaScript/TypeScript thì dùng **StrykerJS** (bản 8.x trở lên):
                                                                    
                                                                    ```bash
                                                                    npm i -D @stryker-mutator/core @stryker-mutator/vitest-runner
                                                                    npx stryker init
                                                                    ```
                                                                    
                                                                    Trong `stryker.config.json`, mình luôn đặt threshold để CI fail khi mutation score tụt:
                                                                    
                                                                    ```json
                                                                    {
                                                                      "testRunner": "vitest",
                                                                        "mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
                                                                          "thresholds": { "high": 80, "low": 60, "break": 50 },
                                                                            "incremental": true
                                                                            }
                                                                            ```
                                                                            
                                                                            Nên bật `incremental: true`, vì mutation testing chạy chậm (N mutant tương đương N lần chạy test). Với repo lớn, mình chỉ chạy trên các file thay đổi trong PR.
                                                                            
                                                                            ## Property-based testing: đừng để AI chọn input
                                                                            
                                                                            Vấn đề thứ hai của test do AI viết là nó tự chọn ví dụ "đẹp", kiểu `100` và `10`. Property-based testing đảo ngược cách làm: bạn mô tả **tính chất luôn đúng**, còn tool tự sinh hàng trăm input, kể cả những edge case bạn không nghĩ ra. Với Python, mình dùng **Hypothesis 6.x**:
                                                                            
                                                                            ```python
                                                                            # tests/test_pricing_props.py
                                                                            import pytest
                                                                            from hypothesis import given, strategies as st
                                                                            from src.pricing import apply_discount
                                                                            
                                                                            prices = st.integers(min_value=0, max_value=10**9)
                                                                            valid_percents = st.integers(min_value=0, max_value=100)
                                                                            
                                                                            @given(price=prices, percent=valid_percents)
                                                                            def test_result_is_int_and_in_range(price, percent):
                                                                                result = apply_discount(price, percent)
                                                                                    assert type(result) is int          # giết mutant // -> /
                                                                                        assert 0 <= result <= price
                                                                                        
                                                                                        @given(price=prices)
                                                                                        def test_boundaries(price):
                                                                                            assert apply_discount(price, 0) == price
                                                                                                assert apply_discount(price, 100) == 0   # giết mutant > -> >=
                                                                                                
                                                                                                @given(percent=st.one_of(st.integers(max_value=-1), st.integers(min_value=101)))
                                                                                                def test_rejects_out_of_range(percent):
                                                                                                    with pytest.raises(ValueError):
                                                                                                            apply_discount(1000, percent)
                                                                                                            ```
                                                                                                            
                                                                                                            Để ý là mình dùng `type(result) is int` chứ không dùng `==`. Đây là kiểu assert mà AI hay bỏ qua. Chạy lại `mutmut run`, hai mutant survived lúc nãy đã bị kill.
                                                                                                            
                                                                                                            Với JS thì có **fast-check**, cách dùng tương tự: `fc.assert(fc.property(fc.integer(), ...))`.
                                                                                                            
                                                                                                            ## Workflow mình đang dùng với AI
                                                                                                            
                                                                                                            Điểm mấu chốt: **tách vai trò**. Đừng để AI vừa đá bóng vừa thổi còi.
                                                                                                            
                                                                                                            ```mermaid
                                                                                                            sequenceDiagram
                                                                                                                participant Dev
                                                                                                                    participant AI
                                                                                                                        participant Mut as mutmut/Stryker
                                                                                                                            Dev->>AI: Viết function + unit test
                                                                                                                                AI-->>Dev: Code + test (coverage cao)
                                                                                                                                    Dev->>Mut: Chạy mutation testing
                                                                                                                                        Mut-->>Dev: Danh sách mutant survived
                                                                                                                                            Dev->>AI: Gửi diff mutant, yêu cầu viết test kill nó
                                                                                                                                                AI-->>Dev: Test mới
                                                                                                                                                    Dev->>Mut: Chạy lại, kiểm tra score
                                                                                                                                                    ```
                                                                                                                                                    
                                                                                                                                                    Vài kinh nghiệm thực tế:
                                                                                                                                                    
                                                                                                                                                    - **Đưa diff của mutant cho AI**, đừng chỉ nói "viết thêm test". Prompt kiểu "mutant này survived, hãy viết test fail với mutant nhưng pass với code gốc" cho kết quả tốt hơn hẳn, vì AI có một mục tiêu cụ thể để "đánh".
                                                                                                                                                    - **Property do người viết**, còn implementation thì để AI viết. Tính chất như "kết quả không bao giờ âm" hay "encode rồi decode ra lại input" là business logic, bạn phải hiểu và tự viết ra.
                                                                                                                                                    - **Không cần đạt 100% mutation score**. Có những mutant tương đương (equivalent mutant), tức là đổi code nhưng hành vi không đổi. Mình đặt mục tiêu khoảng 70-80% cho core logic và bỏ qua phần glue code.
                                                                                                                                                    - **Chạy mutation testing nightly hoặc chỉ trên file thay đổi**, đừng chạy trên mọi commit nếu repo lớn.
                                                                                                                                                    
                                                                                                                                                    ## Kết luận
                                                                                                                                                    
                                                                                                                                                    AI giúp viết test nhanh hơn rất nhiều, nhưng test nhanh mà không bắt được bug thì chỉ là slop có coverage cao. Những việc bạn có thể làm ngay tuần này:
                                                                                                                                                    
                                                                                                                                                    1. Cài `mutmut` (Python) hoặc `@stryker-mutator/core` (JS/TS) và chạy thử trên **một module core** đang có coverage cao. Gần như chắc chắn bạn sẽ thấy mutant survived.
                                                                                                                                                    2. Với mỗi hàm xử lý tiền, ngày giờ hoặc parsing, viết ít nhất **một property test** bằng Hypothesis hoặc fast-check, tập trung vào giá trị biên (0, max, âm).
                                                                                                                                                    3. Đổi cách prompt AI: thay vì "viết test cho hàm này", hãy đưa diff của mutant survived và yêu cầu nó kill mutant đó.
                                                                                                                                                    4. Thêm threshold `break` vào CI để mutation score không âm thầm tụt theo thời gian.
                                                                                                                                                    
                                                                                                                                                    Test tốt phải có khả năng fail khi code sai. Nếu chưa từng thấy test của mình fail lần nào, bạn nên nghi ngờ nó.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí