Improving Weak Tests
Core principle
Every rewrite must answer: what plausible mutation does the new test catch that the old one did not? If you cannot name that mutation in one sentence, the rewrite is not done.
The catalogue below is written in TypeScript against jest/vitest matchers
(toEqual, toHaveBeenCalledWith, expect.any), but the weak-assertion
patterns are language-agnostic — the same rewrites apply to pytest in Python
(see the pytest note under rule 1).
The catalogue
1. toBeTruthy() → exact shape
// WEAK
expect(await saveUser(input)).toBeTruthy();
// STRONG
const result = await saveUser(validInput);
expect(result).toEqual({
id: expect.any(String),
email: validInput.email,
createdAt: expect.any(Date),
});
Catches: saveUser returning {} or echoing the input.
The same weakness in pytest is a bare truthy assert; rewrite it to pin the
exact shape:
# WEAK
assert save_user(valid_input)
# STRONG
from datetime import datetime
result = save_user(valid_input)
assert result.email == valid_input.email
assert isinstance(result.id, str)
assert isinstance(result.created_at, datetime)
2. "mock was called" → state + args + output
// WEAK
await vm.saveUser(input);
expect(httpClient.post).toHaveBeenCalled();
// STRONG
await vm.saveUser(input);
expect(httpClient.post).toHaveBeenCalledWith(`${baseUrl}/users`, input);
expect(vm.users()).toContainEqual(expect.objectContaining({ email: input.email }));
expect(vm.isFormVisible()).toBe(false);
expect(vm.errorMessage()).toBeNull();
Catches: the handler not reloading, not closing the form, or silently failing.
3. Happy path only → add failure path + negative space
it('keeps form open and exposes error when create fails', async () => {
vm.openCreateForm();
const promise = vm.saveUser(validInput);
httpTesting.expectOne('POST', `${baseUrl}/users`)
.error(new ProgressEvent('error'));
await promise;
expect(vm.isFormVisible()).toBe(true);
expect(vm.errorMessage()).toMatch(/create failed|error/i);
expect(vm.isSaving()).toBe(false);
expect(vm.users()).toEqual([]);
});
it('does not issue a write when input is invalid', async () => {
const promise = vm.saveUser(invalidInput);
await promise;
httpTesting.expectNone('POST', `${baseUrl}/users`);
expect(vm.errorMessage()).toMatch(/invalid/i);
});
Catches: silent error swallowing and missing input validation.
4. array.length === N → content + order
// WEAK
expect(vm.users().length).toBe(1);
// STRONG
expect(vm.users()).toEqual([
expect.objectContaining({ id: createdUser.id, email: createdUser.email }),
]);
Catches: returning [anything] instead of the real user.
5. Implementation peek → public contract
If a test reads _internalMap or calls resetForTesting(), rewrite against
a public observable outcome. If no public outcome exists, the behavior has
no observable contract — hand to reviewing-testability rather than keep
the peek.
6. Empty snapshot → hand-crafted structural assertion
// WEAK
expect(vm.state()).toMatchSnapshot();
// STRONG
expect(vm.state()).toEqual({
phase: 'loaded',
users: expect.arrayContaining([
expect.objectContaining({ id: createdUser.id }),
]),
selectedUserId: createdUser.id,
error: null,
});
7. Timeout waits → condition-based waiting
// WEAK
await new Promise(r => setTimeout(r, 100));
expect(vm.loaded()).toBe(true);
// STRONG
await waitFor(() => vm.loaded() === true);
expect(vm.users()).toEqual(expectedUsers);
8. State transition: before → during → after
expect(vm.isSaving()).toBe(false);
const p = vm.saveUser(validInput);
expect(vm.isSaving()).toBe(true);
// flush HTTP
await p;
expect(vm.isSaving()).toBe(false);
expect(vm.users()).toContainEqual(
expect.objectContaining({ email: validInput.email }),
);
Catches: the saving flag never being set, or never being cleared.
Hard rules
- Never use
toBeTruthy(),toBeDefined(),toBeFalsy()as a primary assertion. - Never rely on
toHaveBeenCalled()alone — pair withtoHaveBeenCalledWith(specific)and a state or output assertion, or drop the mock check. - Never assert
.lengthwithout content. - Never leave a behavior with only a positive-path test.
- Never write
expect(true).toBe(true)or equivalent padding. - If you cannot assert something specific because the code shape prevents
it, stop. Hand the case to
reviewing-testabilityrather than writing a compromise test.
Output
For each rewrite, one diff block:
### <file>:<test name>
**Before (WEAK)**
<old body>
**After (STRONG)**
<new body>
**Mutation caught:** one sentence.
Ship the negative-space counterpart in the same unit of work when the original covered only the happy path.
Related
- Before rewriting: run
judging-test-qualityto confirm the verdict. - If rewriting keeps hitting architectural walls:
reviewing-testability. - When driving a whole file or module to full coverage:
mutation-resistant-coverage.