Lê Duy Khương (Daniel)

Chuỗi: cli-to-agent-native · Phần 6

Năng suất & công cụ dev

Test generated code: 4 chiến lược cho code mà không ai viết tay

395 tests, all passing — nhưng nếu generator sai, 395 tests đều sai cùng lúc. Bốn chiến lược test cho generated code mà thực sự cho bạn confidence.

2026-03-178 phút đọcVI

English title: Testing Generated Code — Strategies That Scale


Mở đầu

Tháng 3 năm 2025, một team ở Berlin discovered một bug trong hệ thống của họ sau 6 tháng deploy. Bug không phải trong code — mà trong generator. Template thiếu một edge case. Tất cả 312 generated files đều có cùng bug. Tất cả tests đều pass — vì tests cũng được generate từ cùng template.

Đây là rủi ro đặc trưng của codegen: correlation failure. Khi template sai, generator, code, và test có thể sai cùng lúc — và test suite xanh hết vẫn không có nghĩa là đúng.

Bài này cover 4 chiến lược để test generated code một cách thực sự có ý nghĩa — không phải để có màu xanh trên CI, mà để thực sự có confidence.


1. Nguyên tắc cốt lõi: Test generator, không test output

Khi bạn có 439 generated files, test từng file là:

  • Không scale (test suite lớn dần vô hạn khi API lớn)
  • Không có nghĩa (tests và code share cùng template bug)

Thay vào đó, test generator với representative samples và verify structural properties:

# ✅ Test generator behavior
def test_list_method_generates_pagination():
    """Mọi list method phải có pagination logic."""
    generated = run_generator_for_method(list_method_fixture)
    assert "has_next_page" in generated.adapter_code
    assert "Iterator" in generated.adapter_code
 
# ❌ Test specific generated output (fragile, scales poorly)
def test_user_adapter_exact_content():
    content = open("generated/adapters/user_adapter.py").read()
    assert content == EXACT_EXPECTED_STRING  # Breaks on any whitespace change

2. Chiến lược 1: Snapshot Testing

Snapshot testing capture output của generator và so sánh với "golden file" đã được verify thủ công lần đầu.

# tests/test_snapshots.py
import pytest
from pathlib import Path
from pipeline.generator import render_adapter, render_command
 
 
SNAPSHOTS_DIR = Path("tests/snapshots")
UPDATE_SNAPSHOTS = False  # Set True để update golden files
 
 
def assert_snapshot(name: str, actual: str):
    snapshot_file = SNAPSHOTS_DIR / f"{name}.py"
 
    if UPDATE_SNAPSHOTS or not snapshot_file.exists():
        SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
        snapshot_file.write_text(actual)
        pytest.skip(f"Snapshot created: {name}")
    else:
        expected = snapshot_file.read_text()
        assert actual == expected, (
            f"Snapshot mismatch for {name}.\n"
            f"Run with UPDATE_SNAPSHOTS=True to update.\n"
            f"Diff:\n{unified_diff(expected, actual)}"
        )
 
 
def unified_diff(expected: str, actual: str) -> str:
    import difflib
    return "\n".join(difflib.unified_diff(
        expected.splitlines(), actual.splitlines(),
        lineterm="", fromfile="expected", tofile="actual"
    ))
 
 
SIMPLE_LIST_METHOD = {
    "id": "user.list",
    "resource": "user",
    "action": "list",
    "cli_command": "list-users",
    "http_method": "GET",
    "path": "/users",
    "summary": "List all users",
    "description": "",
    "parameters": [
        {"name": "role", "in": "query", "type": "string", "required": False, "description": "Filter by role"}
    ],
    "paginated": True,
    "idempotent": True,
    "requires_auth": True,
    "tags": ["users"],
}
 
 
def test_adapter_snapshot_simple_list():
    """Simple list method adapter matches golden file."""
    code = render_adapter_to_string("user", [SIMPLE_LIST_METHOD])
    assert_snapshot("adapter_simple_list", code)
 
 
def test_command_snapshot_has_json_flag():
    """Command snapshot includes --json flag."""
    code = render_command_to_string("user", [SIMPLE_LIST_METHOD])
    assert_snapshot("command_simple_list", code)
    # Also verify critical property
    assert "--json" in code

Workflow snapshot testing:

  1. Lần đầu: UPDATE_SNAPSHOTS=True pytest → tạo golden files
  2. Review golden files bằng tay (đây là lúc bạn verify correctness)
  3. Commit golden files vào git
  4. Mọi lần sau: pytest → compare với golden files
  5. Khi intentionally update template: UPDATE_SNAPSHOTS=True pytest → review diff → commit

3. Chiến lược 2: Contract Testing

Contract testing verify generated code match API spec (metadata), không phải match golden string.

# tests/test_contracts.py
import json
import ast
from pathlib import Path
from pipeline.generator import run_pipeline
 
 
def extract_python_functions(code: str) -> list[str]:
    """Parse Python code, return list of function names."""
    tree = ast.parse(code)
    return [
        node.name
        for node in ast.walk(tree)
        if isinstance(node, ast.FunctionDef)
    ]
 
 
def extract_python_classes(code: str) -> list[str]:
    tree = ast.parse(code)
    return [
        node.name
        for node in ast.walk(tree)
        if isinstance(node, ast.ClassDef)
    ]
 
 
class TestAdapterContracts:
    """Verify adapter code satisfies contracts from metadata."""
 
    def setup_method(self):
        self.metadata = json.loads(Path("metadata/api-metadata.json").read_text())
        self.methods = self.metadata["methods"]
        self.generated_dir = Path("generated/")
 
    def test_every_method_has_adapter_implementation(self):
        """Mỗi method trong metadata phải có implementation trong adapter."""
        for method in self.methods:
            resource = method["resource"]
            adapter_file = self.generated_dir / "adapters" / f"{resource}_adapter.py"
            assert adapter_file.exists(), f"Missing adapter for resource: {resource}"
 
            code = adapter_file.read_text()
            expected_func = f"{method['action']}_{resource}"
            functions = extract_python_functions(code)
            assert expected_func in functions, (
                f"Method {method['id']} missing from adapter. "
                f"Expected function: {expected_func}"
            )
 
    def test_every_method_has_cli_command(self):
        """Mỗi method phải có CLI command."""
        for method in self.methods:
            resource = method["resource"]
            command_file = self.generated_dir / "cli" / f"{resource}s.py"
            assert command_file.exists()
 
            code = command_file.read_text()
            cli_command = method["cli_command"]
            # Command decorator: @app.command() trước function với cli_command name
            assert cli_command in code, (
                f"CLI command '{cli_command}' missing from {resource}s.py"
            )
 
    def test_json_flag_present_in_all_commands(self):
        """CRITICAL: Mọi command phải có --json flag (agent-ready requirement)."""
        for command_file in (self.generated_dir / "cli").glob("*.py"):
            code = command_file.read_text()
            # Count command decorators vs json flag occurrences
            command_count = code.count("@app.command()")
            json_flag_count = code.count("--json")
            assert command_count == json_flag_count, (
                f"{command_file.name}: {command_count} commands but {json_flag_count} --json flags. "
                f"Every command must have --json flag."
            )
 
    def test_delete_commands_require_force_flag(self):
        """Destructive commands phải có --force flag."""
        delete_methods = [m for m in self.methods if m["action"] == "delete"]
        for method in delete_methods:
            resource = method["resource"]
            command_file = self.generated_dir / "cli" / f"{resource}s.py"
            code = command_file.read_text()
            assert "--force" in code, (
                f"Delete command for {resource} missing --force flag. "
                f"All destructive operations require explicit confirmation."
            )
 
    def test_exit_codes_defined(self):
        """Mỗi adapter phải define exit codes."""
        for adapter_file in (self.generated_dir / "adapters").glob("*.py"):
            code = adapter_file.read_text()
            assert "AdapterError" in code, (
                f"{adapter_file.name}: Missing AdapterError import/usage"
            )

Contract tests verify properties, không phải exact strings. Khi template thay đổi formatting, contract tests vẫn pass. Chỉ fail khi logic sai.


4. Chiến lược 3: Mock Adapter Testing

Test CLI commands bằng cách mock adapter — không cần real API:

# tests/unit/test_cli_users.py
import json
from unittest.mock import MagicMock, patch
from typer.testing import CliRunner
from generated.cli.users import app
 
 
runner = CliRunner()
 
 
class TestListUsersCommand:
 
    def test_list_users_text_output(self):
        """Default output là text format."""
        mock_users = [
            MagicMock(id=1, name="Alice", email="a@x.com", role="admin"),
            MagicMock(id=2, name="Bob", email="b@x.com", role="member"),
        ]
        with patch("generated.cli.users.UserAdapter") as MockAdapter:
            MockAdapter.return_value.__enter__.return_value.list_users.return_value = iter(mock_users)
            result = runner.invoke(app, ["list-users"])
 
        assert result.exit_code == 0
        assert "Alice" in result.output
        assert "Bob" in result.output
 
    def test_list_users_json_output(self):
        """--json flag trả về valid JSON."""
        mock_users = [
            MagicMock(id=1, name="Alice", email="a@x.com", role="admin"),
        ]
        with patch("generated.cli.users.UserAdapter") as MockAdapter:
            MockAdapter.return_value.__enter__.return_value.list_users.return_value = iter(mock_users)
            result = runner.invoke(app, ["list-users", "--json"])
 
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert isinstance(data, list)
        assert data[0]["name"] == "Alice"
 
    def test_auth_error_returns_exit_code_4(self):
        """Auth error → exit code 4."""
        from generated.adapters.user_adapter import AdapterError
        with patch("generated.cli.users.UserAdapter") as MockAdapter:
            MockAdapter.return_value.__enter__.return_value.list_users.side_effect = \
                AdapterError("auth", "Invalid token", exit_code=4)
            result = runner.invoke(app, ["list-users", "--json"])
 
        assert result.exit_code == 4
 
    def test_delete_without_force_returns_exit_code_2(self):
        """Delete without --force → usage error (exit code 2)."""
        result = runner.invoke(app, ["delete-user", "42"])
        assert result.exit_code == 2
 
 
class TestDeleteUserCommand:
 
    def test_delete_with_force_succeeds(self):
        """--force flag cho phép delete proceed."""
        with patch("generated.cli.users.UserAdapter") as MockAdapter:
            MockAdapter.return_value.__enter__.return_value.delete_user.return_value = None
            result = runner.invoke(app, ["delete-user", "42", "--force"])
 
        assert result.exit_code == 0

5. Chiến lược 4: Integration Testing

Integration tests gọi real adapter với mocked HTTP:

# tests/integration/test_user_adapter.py
import pytest
import respx
import httpx
from generated.adapters.user_adapter import UserAdapter, AdapterError
 
 
BASE_URL = "https://api.example.com/v1"
 
 
@pytest.fixture
def adapter():
    return UserAdapter(token="test-token", base_url=BASE_URL)
 
 
class TestListUsersIntegration:
 
    @respx.mock
    def test_list_handles_pagination(self, adapter):
        """Adapter tự động handle multi-page response."""
        # Page 1
        respx.get(f"{BASE_URL}/users").mock(
            side_effect=[
                httpx.Response(200, json={
                    "items": [{"id": 1, "name": "Alice", "email": "a@x.com", "role": "admin"}],
                    "has_next_page": True
                }),
                # Page 2
                httpx.Response(200, json={
                    "items": [{"id": 2, "name": "Bob", "email": "b@x.com", "role": "member"}],
                    "has_next_page": False
                }),
            ]
        )
 
        users = list(adapter.list_users())
        assert len(users) == 2
        assert users[0].name == "Alice"
        assert users[1].name == "Bob"
 
    @respx.mock
    def test_401_raises_adapter_error_with_exit_code_4(self, adapter):
        respx.get(f"{BASE_URL}/users/999").mock(
            return_value=httpx.Response(401, json={"message": "Unauthorized"})
        )
 
        with pytest.raises(AdapterError) as exc_info:
            adapter.get_user(999)
 
        assert exc_info.value.exit_code == 4
        assert exc_info.value.code == "auth"
 
    @respx.mock
    def test_ensure_user_idempotent(self, adapter):
        """ensure_user gọi 2 lần với cùng email → action = already_exists lần 2."""
        respx.get(f"{BASE_URL}/users").mock(
            side_effect=[
                # First call: not found
                httpx.Response(200, json={"items": [], "has_next_page": False}),
                # create succeeds
            ]
        )
        respx.post(f"{BASE_URL}/users").mock(
            return_value=httpx.Response(201, json={"id": 1, "name": "Alice", "email": "a@x.com", "role": "member"})
        )
 
        user1, action1 = adapter.ensure_user("Alice", "a@x.com")
        assert action1 == "created"

6. Test pyramid cho generated code

           ┌─────────┐
           │  E2E    │  ← Ít, chậm: test real API (optional, staging)
           └─────────┘
         ┌─────────────┐
         │ Integration │  ← Mock HTTP (respx): test adapter logic
         └─────────────┘
       ┌─────────────────┐
       │   CLI (mock)    │  ← Mock adapter: test CLI behavior
       └─────────────────┘
     ┌─────────────────────┐
     │  Contract Testing   │  ← Verify generated code matches spec
     └─────────────────────┘
   ┌─────────────────────────┐
   │   Snapshot Testing      │  ← Prevent unintended template changes
   └─────────────────────────┘
 ┌─────────────────────────────┐
 │   Generator Unit Testing    │  ← Test generator logic itself (base)
 └─────────────────────────────┘

Phần lớn confidence đến từ 3 tầng dưới cùng — generator tests, snapshot tests, contract tests. Đây là những tests không bị correlation failure vì chúng test generator logic hoặc structural properties, không phải test generated output bằng generated test.


7. Ứng dụng trong AI-centric engineering

Khi agent gọi CLI, agent không biết có tests hay không. Nhưng agent biết exit codes và JSON output shape. Contract tests đảm bảo:

  1. Mọi command có --json → agent luôn có option nhận structured output
  2. Mọi delete command có --force → agent không thể vô tình delete
  3. Exit codes consistent → agent branch logic works correctly

Đây là lý do testing strategy cho codegen pipeline không chỉ là quality engineering — đó là agent reliability.

Với 395 tests pass (từ procurement CLI thực tế), confidence không đến từ "395 tests xanh". Nó đến từ test pyramid: generator tests verify logic, contract tests verify properties, snapshot tests prevent regression, integration tests verify behavior. Cùng nhau, chúng cover các loại failure khác nhau.


Bài tiếp

Bài 7: Making Your CLI Agent-Discoverable — MCP Server — Từ CLI đến MCP server: wrap adapter trong 20 dòng code, expose qua Model Context Protocol để agent tự discover và invoke. Token cost: 55,000 tokens upfront. Khi nào dùng MCP, khi nào không — và tại sao Perplexity CTO rời bỏ MCP.

LDK

Le Duy Khuong

AI Transformation & Digital Strategy. Writing about agentic systems, engineering leadership, and building in public.