| name | unit-testing |
| description | Use when writing unit tests for a function or module, mocking external dependencies, practising TDD on new business logic, or enforcing a coverage target across a codebase in Python, TypeScript, or Go. |
Unit Testing
Structured guidance for writing reliable, maintainable unit tests — covering test anatomy, mocking strategies, parameterized tests, TDD workflow, coverage strategy, and test organization across Python, TypeScript, and Go.
When to Activate
- Writing tests for a function, class, or module
- Setting up a test framework for a new project
- Mocking or stubbing external dependencies in tests
- Deciding what to unit test vs integration test
- Practising TDD (test-driven development) on a new feature
- Achieving or enforcing a coverage target
- Debugging a flaky or unclear test failure
Test Anatomy
Arrange / Act / Assert (AAA)
Every test has three distinct phases:
- Arrange — set up the inputs, dependencies, and state needed for the test
- Act — call the unit under test (exactly once)
- Assert — verify the outcome matches expectations
One assertion per logical concern means each test validates one observable behaviour. Multiple assert statements are fine as long as they all verify the same thing (e.g., both fields of a returned object).
Test Naming
| Language | Convention | Example |
|---|
| Python | test_<unit>_<scenario>_<expected_result> | test_divide_by_zero_raises_value_error |
| TypeScript | describe('Unit') / it('should ...') | it('should throw when dividing by zero') |
| Go | TestXxx with t.Run("SubtestName", ...) | TestDivide/ByZero |
Well-Structured Test Examples
def test_calculate_total_with_discount_returns_reduced_price():
cart = Cart(items=[Item(price=100)])
discount = PercentageDiscount(rate=0.10)
total = calculate_total(cart, discount)
assert total == 90.0
describe('calculateTotal', () => {
it('should return reduced price when discount is applied', () => {
const cart = { items: [{ price: 100 }] };
const discount = { rate: 0.10 };
const total = calculateTotal(cart, discount);
expect(total).toBe(90.0);
});
});
func TestCalculateTotal_WithDiscount_ReturnsReducedPrice(t *testing.T) {
cart := Cart{Items: []Item{{Price: 100}}}
discount := Discount{Rate: 0.10}
total := CalculateTotal(cart, discount)
if total != 90.0 {
t.Errorf("expected 90.0, got %f", total)
}
}
What to Unit Test
Decision Table
| Test This | Don't Test This |
|---|
| Public functions and methods | Private implementation details |
| Business logic and domain rules | Framework internals (ORM, router) |
| Edge cases: nil/None/null, empty collections | Trivial getters and setters |
| Boundary values (off-by-one, max/min) | Auto-generated code (protobuf, ORM models) |
| Error paths and exception handling | Configuration loading (use integration tests) |
| Conditional branches | main() entrypoints |
| Data transformations and calculations | Third-party library behaviour |
Principles
Test public interfaces. If you must reach into private methods to test behaviour, that is a signal the logic should be extracted into its own testable unit.
Test edge cases explicitly. The most common bugs live at the boundaries:
def test_get_first_item():
result = get_first([1, 2, 3])
assert result == 1
def test_get_first_item_returns_none_for_empty_list():
result = get_first([])
assert result is None
Test error paths. If your function raises, throws, or returns an error value, test that explicitly:
it('should throw InvalidInputError when name is empty', () => {
expect(() => createUser({ name: '' })).toThrow(InvalidInputError);
});
func TestCreateUser_EmptyName_ReturnsError(t *testing.T) {
_, err := CreateUser(UserInput{Name: ""})
if err == nil {
t.Fatal("expected error for empty name, got nil")
}
}
Mocking, Stubbing, and Spying
Terminology
| Term | Definition | Use When |
|---|
| Stub | Returns a fixed value; does not record calls | Replacing a dependency that returns data |
| Mock | Records calls; assertions on how it was used | Verifying a side-effect was triggered |
| Spy | Wraps real implementation; records calls | Checking interactions without replacing logic |
| Fake | Lightweight working implementation (e.g., in-memory DB) | Stateful dependencies like repositories |
Python — unittest.mock and pytest-mock
from unittest.mock import MagicMock, patch
import pytest
def test_send_welcome_email_calls_mailer():
mailer = MagicMock()
service = UserService(mailer=mailer)
service.register(email="user@example.com")
mailer.send.assert_called_once_with(
to="user@example.com",
subject="Welcome"
)
def test_get_current_time_uses_utcnow(mocker):
mock_now = mocker.patch("mymodule.datetime")
mock_now.utcnow.return_value = datetime(2024, 1, 1)
result = get_current_time()
assert result == datetime(2024, 1, 1)
TypeScript — Jest and Vitest
const mockSend = jest.fn().mockResolvedValue({ status: 200 });
const mailer = { send: mockSend };
const service = new UserService(mailer);
await service.register('user@example.com');
expect(mockSend).toHaveBeenCalledWith({
to: 'user@example.com',
subject: 'Welcome',
});
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
triggerDeprecatedFeature();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
import { vi } from 'vitest';
const mockFn = vi.fn();
const spyFn = vi.spyOn(obj, 'method');
Go — Interface-Based Mocking
Go's idiomatic approach is to define an interface for the dependency and provide a test struct that implements it.
type Mailer interface {
Send(to, subject string) error
}
type MockMailer struct {
CalledWith []struct{ To, Subject string }
}
func (m *MockMailer) Send(to, subject string) error {
m.CalledWith = append(m.CalledWith, struct{ To, Subject string }{to, subject})
return nil
}
func TestUserService_Register_SendsWelcomeEmail(t *testing.T) {
mailer := &MockMailer{}
svc := NewUserService(mailer)
svc.Register("user@example.com")
if len(mailer.CalledWith) != 1 {
t.Fatalf("expected 1 call, got %d", len(mailer.CalledWith))
}
if mailer.CalledWith[0].To != "user@example.com" {
t.Errorf("unexpected recipient: %s", mailer.CalledWith[0].To)
}
}
When Mocking Is a Design Smell
If every test requires five mocks to run a single function, the function has too many dependencies. Excessive mocking signals tight coupling — consider refactoring toward pure functions, dependency injection, or smaller units.
def test_process_order():
order_service = MagicMock()
order_service.process.return_value = True
assert order_service.process(order) is True
def test_process_order_persists_to_repository():
repo = MagicMock()
service = OrderService(repo=repo)
service.process(order)
repo.save.assert_called_once_with(order)
Parameterized Tests
Use parameterized tests when the same logic needs to be verified against many input/output pairs. This avoids copy-pasting test bodies and makes the full test matrix visible at a glance.
Example: validate_email() across 5 cases
import pytest
@pytest.mark.parametrize("email,expected", [
("user@example.com", True),
("USER@EXAMPLE.COM", True),
("missing-at-sign", False),
("@nodomain.com", False),
("", False),
], ids=[
"valid_standard",
"valid_uppercase",
"invalid_no_at",
"invalid_no_local_part",
"invalid_empty",
])
def test_validate_email(email, expected):
assert validate_email(email) == expected
describe('validateEmail', () => {
it.each([
['user@example.com', true, 'valid standard'],
['USER@EXAMPLE.COM', true, 'valid uppercase'],
['missing-at-sign', false, 'invalid no at'],
['@nodomain.com', false, 'invalid no local part'],
['', false, 'invalid empty'],
])('should return %s for "%s" (%s)', (email, expected) => {
expect(validateEmail(email)).toBe(expected);
});
});
func TestValidateEmail(t *testing.T) {
cases := []struct {
name string
email string
expected bool
}{
{"valid standard", "user@example.com", true},
{"valid uppercase", "USER@EXAMPLE.COM", true},
{"invalid no at", "missing-at-sign", false},
{"invalid no local part", "@nodomain.com", false},
{"invalid empty", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := ValidateEmail(tc.email)
if result != tc.expected {
t.Errorf("ValidateEmail(%q): got %v, want %v", tc.email, result, tc.expected)
}
})
}
}
Test Doubles for External I/O
Fakes vs Mocks
| Approach | Best For | Example |
|---|
| Fake | Stateful dependencies with real behaviour | In-memory repository |
| Mock | Fire-and-forget calls where you verify interaction | Email sender, event bus |
| HTTP stub | Outbound HTTP clients | responses, msw, httptest |
Use fakes for repositories and other stateful collaborators — they give you real insert/find behaviour without a live database. Use mocks for fire-and-forget side-effects (emails, queues) where the key assertion is that the call was made at all.
In-Memory Repository (Fake)
class InMemoryUserRepository:
def __init__(self):
self._store = {}
def save(self, user):
self._store[user.id] = user
def find_by_id(self, user_id):
return self._store.get(user_id)
class InMemoryUserRepository implements UserRepository {
private store = new Map<string, User>();
async save(user: User): Promise<void> {
this.store.set(user.id, user);
}
async findById(id: string): Promise<User | undefined> {
return this.store.get(id);
}
}
type InMemoryUserRepo struct {
store map[string]User
}
func NewInMemoryUserRepo() *InMemoryUserRepo {
return &InMemoryUserRepo{store: make(map[string]User)}
}
func (r *InMemoryUserRepo) Save(u User) error {
r.store[u.ID] = u
return nil
}
func (r *InMemoryUserRepo) FindByID(id string) (User, bool) {
u, ok := r.store[id]
return u, ok
}
Mocking HTTP Clients
import responses as rsps
import requests
@rsps.activate
def test_fetch_user_returns_parsed_data():
rsps.add(rsps.GET, "https://api.example.com/users/1",
json={"id": 1, "name": "Alice"}, status=200)
user = fetch_user(1)
assert user.name == "Alice"
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users/:id', () =>
HttpResponse.json({ id: 1, name: 'Alice' })
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('should return parsed user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
func TestFetchUser_ReturnsUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"id":1,"name":"Alice"}`)
}))
defer server.Close()
client := NewAPIClient(server.URL)
user, err := client.FetchUser(1)
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
}
TDD Workflow
Red → Green → Refactor
- Red — write a failing test that specifies the behaviour you want before writing any implementation
- Green — write the minimum code required to make the test pass, even if the code is ugly
- Refactor — clean up duplication and improve design with the safety net of a passing test
When TDD Pays Off
| Situation | TDD Value |
|---|
| New feature with clear acceptance criteria | High |
| Complex business logic (pricing, validation, state machines) | High |
| Regression prevention on a known bug | High |
| Exploratory / spike code | Low — write tests after the design emerges |
| UI layout and visual components | Low — prefer visual regression tools |
| Third-party integration spike | Low — integration tests are more appropriate |
Outside-In TDD
Start from the outer boundary (e.g., an HTTP handler or a use case) and write a failing acceptance test. Mock all inner collaborators. Then drive out each collaborator with its own unit tests, working inward until all mocks are replaced by real implementations.
Coverage Strategy
What Each Metric Measures
| Metric | Measures | Limitation |
|---|
| Line coverage | Whether each line was executed | A line can be hit without testing all outcomes |
| Branch coverage | Whether each if/else path was taken | Misses logic errors in the execution path |
| Mutation testing | Whether tests detect small code changes | Slow; use on critical modules only |
Realistic Targets
| Context | Line Coverage | Branch Coverage |
|---|
| Greenfield project | 80%+ | 70%+ |
| Existing codebase | Raise incrementally by 5% per sprint | Focus on new code |
| Critical path (payments, auth, core domain) | 95%+ | 90%+ |
| Generated or migration code | Exclude | Exclude |
What to Exclude from Coverage
- Database migration files
- Auto-generated code (protobuf, ORM schema)
main() entrypoints and CLI bootstrap
- Configuration loaders and environment readers
- Third-party adapter shims
Tools
pytest --cov=src --cov-report=term-missing --cov-branch
export default {
collectCoverage: true,
coverageThreshold: { global: { lines: 80, branches: 70 } },
coveragePathIgnorePatterns: ['/generated/', '/migrations/'],
};
export default defineConfig({
test: {
coverage: { provider: 'v8', thresholds: { lines: 80, branches: 70 } },
},
});
go test ./... -cover -coverprofile=coverage.out
go tool cover -html=coverage.out
Coverage is a floor, not a goal. 100% line coverage achieved by testing only the happy path with no meaningful assertions is worse than 70% coverage with rigorous, well-structured tests.
Test Organization
Co-location vs Separate Directory
| Language | Convention | Notes |
|---|
| Python | tests/ at project root | Mirrors src/ structure; conftest.py for shared fixtures |
| TypeScript | *.test.ts or *.spec.ts co-located, or __tests__/ folder | Co-location is most common in React/Node projects |
| Go | _test.go suffix, same directory as source | Same package = white-box; package foo_test suffix = black-box |
Python: conftest.py and Fixtures
import pytest
from myapp.db import Database
@pytest.fixture
def db():
database = Database(url="sqlite:///:memory:")
database.create_tables()
yield database
database.drop_tables()
@pytest.fixture
def user_repo(db):
return UserRepository(db)
Go: White-Box vs Black-Box Tests
package cart
func TestInternalDiscount(t *testing.T) {
d := applyInternalDiscount(100, 0.10)
if d != 90.0 {
t.Errorf("expected 90.0, got %f", d)
}
}
package cart_test
func TestCart_AddItem(t *testing.T) {
c := cart.New()
c.Add(cart.Item{Price: 50})
if c.Total() != 50 {
t.Errorf("expected total 50, got %f", c.Total())
}
}
Test Isolation
_global_cache = {}
def test_a():
_global_cache["key"] = "value"
def test_b():
assert _global_cache.get("key") is None
def test_b():
cache = {}
result = process_with_cache(cache, "key")
assert result is not None
Clean up after each test. Use setUp/tearDown, pytest fixtures with yield, Jest's beforeEach/afterEach, or Go's t.Cleanup() to reset shared resources.
Parallel Test Execution
| Tool | Parallel Flag | Notes |
|---|
| pytest | pytest-xdist: pytest -n auto | Tests must be stateless; use tmp_path fixture for filesystem |
| Jest | --maxWorkers=4 (parallel) or --runInBand (serial) | Use --runInBand when tests share a real database |
| Vitest | pool: 'threads' (default) or pool: 'forks' | Configure in vitest.config.ts |
| Go | t.Parallel() inside each test | Call at the top of the test function; subtests inherit the pool |
func TestCalculate(t *testing.T) {
t.Parallel()
t.Run("adds correctly", func(t *testing.T) {
t.Parallel()
result := Add(2, 3)
if result != 5 {
t.Errorf("expected 5, got %d", result)
}
})
}
Red Flags
- Testing implementation details instead of behavior — asserting on private method calls or internal state ties tests to refactoring; test what the unit does, not how it does it
- Mocking the unit under test — a mock of the same class being tested verifies nothing; mocks belong at external boundaries only (DB, HTTP, filesystem)
- 100% line coverage as the target — chasing line coverage produces tests with no assertions ("did it run?" not "did it work?"); track branch coverage and mutation score instead
setUp that builds shared mutable state — shared state between tests creates ordering dependencies and flaky failures; each test must create its own independent fixtures
sleep() or time delays in tests — time-dependent tests are inherently flaky; inject a clock abstraction and control time explicitly in tests
- Testing private methods directly — private methods are implementation details; test them through the public interface that uses them, or extract them into a separate collaborator
- No tests for error paths — testing only the happy path misses the most common production bugs; every test file should cover each error condition and boundary case
Checklist