Test Principles - Test Design Principles
A guide to writing maintainable and meaningful tests by applying 6 test design principles based on Google's "Software Engineering at Google".
Tests are not "proof that it works" but a "safety net for future changes". Fragile tests do not serve as a safety net; instead, they slow down development. These principles are guidelines to make tests valuable in the long term.
Core Principles
1. Test behavior, not methods
The structure of tests should be determined by "user-visible behavior" rather than method names. If tests are divided based on method names, you will have to rewrite tests every time you refactor. With behavior-based tests, tests won't break as long as the behavior remains the same even if the internal implementation changes.
- If a single method has multiple behaviors, write separate tests for each behavior
- If a single behavior spans multiple methods, cover it with one test
- Name test cases in Japanese to describe the behavior
go
// NG: Method-name based
func TestCalculatePrice(t *testing.T) { ... }
// OK: Behavior-based
func TestCalculatePrice_ReturnsDiscountedPriceWhenDiscountIsApplied(t *testing.T) { ... }
func TestCalculatePrice_ReturnsErrorWhenStockIsOutOfStock(t *testing.T) { ... }
2. Prefer DAMP over DRY
DAMP = Descriptive And Meaningful Phrases. Prioritize readability over reusability in test code. Each test case should be completely understandable just by reading that test alone.
When a test fails, developers want to understand the problem by reading only that test case. Tests that require reading 3 files to trace helper functions significantly reduce debugging efficiency.
- Write test data inline within the test case
- You can use helper functions, but avoid excessive abstraction that hides the test's intent
- Each case in a table-driven test should include all information necessary for verification
go
// NG: Important values are hidden deep in a helper
order := createDefaultOrder()
// OK: Values needed for the test are visible
order := &Order{
ID: "order-001",
Status: StatusPending,
CreatedAt: time.Date(2025, 1, 1, 10, 0, 0, 0, time.UTC),
}
3. No logic in tests
Do not include
,
, or
in test code. Do not dynamically calculate values in tests. Write expected values as literals.
If tests contain logic, when a test fails, it's impossible to distinguish whether it's an "implementation bug" or a "test bug". Tests should follow a linear flow of "given → execute → verify" to ensure the reliability of the tests themselves.
go
// NG: Calculating expected value
want := basePrice * (1 - discountRate)
// OK: Writing expected value as a literal
want := 800 // 20% discount on 1000 yen
4. Test state, not interactions
Verify the final state or result rather than the number of mock calls or argument order. Interaction tests are coupled to internal implementation details, making them the main cause of broken tests during refactoring.
- Verify "what the result was" rather than "what happened"
- Limit mocks to replacing external dependencies (APIs, DBs)
- Prefer tests using actual data stores when possible
go
// NG: Dependent on call count
if mockRepo.callCount != 3 { t.Error(...) }
// OK: Verify final result
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
5. Test through public APIs
Test through the public interface of a module. Do not directly test private methods or internal implementation details.
Private methods are implementation details that should be freely modifiable during refactoring. Testing through public APIs ensures that tests won't break even if the internal structure changes, and guarantees that the public API contract is upheld.
- Test through exported functions and methods
- Do not test the behavior of frameworks or libraries
- Do not export internal state for testing purposes
6. Be complete and concise
Each test should verify only one behavior. Include all information necessary for the test within the test, and minimize setup unrelated to verification.
- Accurately describe the behavior being verified in the test name
- Do not create tests that require reading other tests to understand
- Omit setup of unnecessary fields
Subcommands
review - Test Code Review
Identify the test files to review from
and review them based on the 6 principles.
Steps:
- Search for target test files with and read them with
- Also read the implementation code being tested (to confirm public API testing)
- Detect issues against each principle
- Report review results in the following format
Check Items:
- Are tests structured based on behavior?
- Is each test case self-contained and understandable just by reading it?
- Does the test code contain logic (if/for/switch)?
- Is the final state verified rather than mock call counts?
- Are private methods or framework behaviors being tested?
- Does each test cover only one behavior?
- Does it comply with the project's test conventions?
Report Format:
## Test Principles Review
Target: [File Path]
### Principle Violations
#### [Principle Name]
- Location: filepath:line
- Issue: Description of the problem
- Improvement Suggestion: Specific improvement method
### Overall Evaluation
- Number of Violations: [N]
- Improvement Priority: High/Medium/Low
refactor - Test Refactoring
Identify target test files from
and refactor them based on the 6 principles.
Steps:
- Read the target test files and the implementation code being tested
- Identify issues using the same check items as review
- Fix in the following priority order:
a. Tests with logic → Convert to linear tests
b. Method-based structure → Restructure to behavior-based
c. Interaction tests → Convert to state tests
d. Private API tests → Convert to public API tests
e. Excessively DRY helpers → Inline test data
f. Single test with multiple behaviors → Split
- Delete unnecessary tests (framework behavior tests, only constant matches, etc.)
- Run tests to confirm all pass
Notes:
- Maintain behavior coverage. A reduction in the number of tests is not a problem
- Comply with the project's test conventions
write - New Test Creation
Identify the target implementation code from
and create new tests following the 6 principles.
Steps:
- Read the target implementation code
- Confirm naming and placement conventions of existing test files with
- Identify public APIs (exported functions and methods)
- Enumerate the behaviors of each public API:
- Normal behavior
- Edge cases (boundary values, empty input, nil)
- Error cases
- Design test cases for each behavior
- Write tests (table-driven test pattern is required)
- Run tests to confirm all pass
Test Design Guidelines:
- Structure with table-driven tests (
tests := []struct{...}{...}
pattern)
- Name tests in Japanese Given-When-Then format
- Write test data inline within each case
- Write expected values as literals
- Compare structs/slices/maps with
- Use mocks only for replacing external dependencies
- Each test follows the flow of Given (setup) / When (execution) / Then (verification) (no comments needed, separate with blank lines)
Judgment Criteria
Guidelines when unsure whether to write a test:
| Tests to Write | Tests Not to Write |
|---|
| Business logic behavior | Exact matches of constant values |
| Each path of conditional branches | Framework functions |
| Error handling | Code with only getters/setters |
| Data conversion logic | Behavior of external libraries |
| Boundary values and edge cases | Internal implementation details |