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.
Installs into .claude/skills of the current project.
Are you the author of Unit Testing?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/kid-sid-unit-testing-claude-spellbook)
---
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
```python
# Python — pytest
def test_calculate_total_with_discount_returns_reduced_price():
# Arrange
cart = Cart(items=[Item(price=100)])
discount = PercentageDiscount(rate=0.10)
# Act
total = calculate_total(cart, discount)
# Assert
assert total == 90.0
```
```typescript
// TypeScript — Jest / Vitest
describe('calculateTotal', () => {
it('should return reduced price when discount is applied', () => {
// Arrange
const cart = { items: [{ price: 100 }] };
const discount = { rate: 0.10 };
// Act
const total = calculateTotal(cart, discount);
// Assert
expect(total).toBe(90.0);
});
});
```
```go
// Go — testing package
func TestCalculateTotal_WithDiscount_ReturnsReducedPrice(t *testing.T) {
// Arrange
cart := Cart{Items: []Item{{Price: 100}}}
discount := Discount{Rate: 0.10}
// Act
total := CalculateTotal(cart, discount)
// Assert
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:
```python
# BAD — only tests the happy path
def test_get_first_item():
result = get_first([1, 2, 3])
assert result == 1
# GOOD — also tests the edge case
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:
```typescript
// TypeScript
it('should throw InvalidInputError when name is empty', () => {
expect(() => createUser({ name: '' })).toThrow(InvalidInputError);
});
```
```go
// Go
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`
```python
from unittest.mock import MagicMock, patch
import pytest
# Stub with MagicMock
def test_send_welcome_email_calls_mailer():
mailer = MagicMock()
service = UserService(mailer=mailer)
service.register(email="user@example.com")
# Mock assertion — verify interaction
mailer.send.assert_called_once_with(
to="user@example.com",
subject="Welcome"
)
# Patch with pytest-mock
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
```typescript
// jest.fn() — stub / mock
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',
});
// jest.spyOn() — spy on real method
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
triggerDeprecatedFeature();
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
// Vitest equivalents (same API)
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.
```go
// Production interface
type Mailer interface {
Send(to, subject string) error
}
// Test double — implement the interface manually
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
}
// Test
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.
```python
# BAD — mocking the system under test
def test_process_order():
order_service = MagicMock() # mocking the thing being tested
order_service.process.return_value = True
assert order_service.process(order) is True # tests nothing real
# GOOD — mock only the external boundary
def test_process_order_persists_to_repository():
repo = MagicMock()
service = OrderService(repo=repo) # inject the dependency
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
```python
# Python — pytest.mark.parametrize with IDs
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
```
```typescript
// TypeScript — Jest test.each / Vitest it.each
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);
});
});
```
```go
// Go — table-driven tests with t.Run
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)
```python
# Python — fake repository for stateful DB calls
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)
```
```typescript
// TypeScript — fake repository
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);
}
}
```
```go
// Go — fake repository struct
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
```python
# Python — responses library
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"
```
```typescript
// TypeScript — msw (Mock Service Worker)
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');
});
```
```go
// Go — httptest.NewServer
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
```python
# Python — pytest-cov
pytest --cov=src --cov-report=term-missing --cov-branch
```
```typescript
// TypeScript — Jest
// jest.config.ts
export default {
collectCoverage: true,
coverageThreshold: { global: { lines: 80, branches: 70 } },
coveragePathIgnorePatterns: ['/generated/', '/migrations/'],
};
// Vitest — vitest.config.ts
export default defineConfig({
test: {
coverage: { provider: 'v8', thresholds: { lines: 80, branches: 70 } },
},
});
```
```bash
# Go
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
```python
# tests/conftest.py — shared fixtures available to all tests
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
```go
// white_box_test.go — same package, can access unexported identifiers
package cart
func TestInternalDiscount(t *testing.T) {
d := applyInternalDiscount(100, 0.10) // unexported function
if d != 90.0 {
t.Errorf("expected 90.0, got %f", d)
}
}
// black_box_test.go — _test package suffix, only public API visible
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
```python
# BAD — shared mutable state between tests
_global_cache = {}
def test_a():
_global_cache["key"] = "value"
def test_b():
assert _global_cache.get("key") is None # fails if test_a ran first
# GOOD — each test owns its state
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 |
```go
func TestCalculate(t *testing.T) {
t.Parallel() // opt in to parallel execution
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
- [ ] Tests follow AAA pattern with clear arrange/act/assert separation
- [ ] Test names describe the scenario and expected outcome
- [ ] Each test has a single logical assertion (one behaviour per test)
- [ ] External dependencies (DB, HTTP, filesystem) are mocked or faked
- [ ] Edge cases covered: null/None/nil, empty collections, boundary values, error paths
- [ ] Parameterized tests used for multiple input/output scenarios of the same logic
- [ ] No test relies on execution order or shared mutable state
- [ ] Coverage meets project threshold for both line and branch coverage
- [ ] Flaky tests are quarantined or fixed before merging to main
- [ ] Test suite runs in under 60 seconds locally
- [ ] Public interfaces are tested, not private implementation details
- [ ] Mocks are only used for external boundaries, not for the unit under test
- [ ] Fixtures and fakes are cleaned up after each test (no state leakage)
- [ ] TDD red/green/refactor cycle followed for new business logic
- [ ] Generated, migration, and configuration code is excluded from coverage requirements