unit-testing
Unit test patterns for ESM/gradle Hub modules. Use when writing test/unit/*.test.ts files.
SKILL.md.claude/skills/unit-testing/SKILL.md
GitHubUnit tests
Unit tests live in test/unit/. They mock HTTP with nock, run without credentials, work offline, and finish fast. zbb test --slot local runs them.
Shape of a unit test file
import { expect, assert } from 'chai';
import nock from 'nock';
import { Email, NotFoundError, InvalidCredentialsError }
from '@zerobias-org/types-core-js';
import { new<Class> } from '../../src/index.js';
describe('<Tag>Producer', () => {
afterEach(() => nock.cleanAll());
describe('get<Resource>', () => {
it('should retrieve a <resource> by id', async () => {
nock('https://api.example.com')
.get('/<resource>/123')
.reply(200, { id: '123', name: 'Foo' });
const api = new<Class>();
await api.connect({ apiToken: 'x' });
const result = await api.get<Tag>Api().get<Resource>('123');
expect(result).to.have.property('id', '123');
expect(nock.pendingMocks()).to.be.empty;
});
it('should throw NotFoundError when the <resource> does not exist', async () => {
nock('https://api.example.com')
.get('/<resource>/999')
.reply(404, { error: 'not_found' });
const api = new<Class>();
await api.connect({ apiToken: 'x' });
try {
await api.get<Tag>Api().get<Resource>('999');
assert.fail('should have thrown');
} catch (e) {
expect(e).to.be.instanceOf(NotFoundError);
}
});
});
});
Notes baked into the shape above:
import nock from 'nock'— default import for nock 14 / ESM. Theimport * as nockform does not work.from '../../src/index.js'— relative imports always end in.js, even though the source is.ts.afterEach(() => nock.cleanAll())— prevents cross-test mock leakage. Use this at suite level or inside eachdescribe().nock.pendingMocks()— at the end of a test, this should be empty. If it's not, a mock you set up wasn't consumed, which usually means the producer URL didn't match.- Use core error types —
NotFoundError,InvalidCredentialsError,RateLimitExceededError,UnexpectedError. PlainErrorassertions don't lock in the producer's error mapping.
Optional test/unit/Common.ts
When most tests share the same setup (connect + mocks), factor it out:
import nock from 'nock';
import { new<Class> } from '../../src/index.js';
import type { <Class> } from '../../src/index.js';
export async function newConnectedClient(): Promise<<Class>> {
nock('https://api.example.com')
.post('/auth/login')
.reply(200, { accessToken: 'test-token' });
const api = new<Class>();
await api.connect({ apiToken: 'test-token' });
return api;
}
Common.ts is conventional but not auto-scaffolded. Only add it when the duplication is real. Never read process.env from it.
Connection tests
Every connector module should have test/unit/ConnectionTest.test.ts covering:
- happy connect (200 → state set,
isConnected()true) - failed connect (401 →
InvalidCredentialsError) isConnected()false beforeconnect()disconnect()clears state,isConnected()becomes falsemetadata()returns a shape withstatus
This is the minimum that proves the lifecycle methods aren't lying.
Producer tests
One file per <Tag>Api from the generated interface. For each operation:
- happy path with a representative response body
- one error path (404, 429, or 401 as appropriate)
- if the operation paginates: a multi-page test that verifies all pages are consumed
For mappers, the test should assert against shaped data, not just type:
expect(result).to.deep.equal({
id: '123',
email: 'a@example.com',
createdAt: new Date('2025-01-01T00:00:00Z'),
});
Use deep.equal (not just property checks) when the mapper has many fields — that catches missing fields and wrong field names in one assertion.
Fixtures
Real, sanitized API responses live in test/unit/fixtures/ (gitignored or committed as appropriate). Strip all PII before committing:
| Real | Sanitized |
|---|---|
j.doe@google.com |
user@example.com |
Jane Doe |
Jane Doe |
4111 1111 … |
4111111111111111 |
| API keys | test-token-xxx |
| Stripe IDs | cus_test_xxx |
Import them as plain objects:
import userFixture from './fixtures/user-123.json' with { type: 'json' };
nock('https://api.example.com')
.get('/users/123')
.reply(200, userFixture);
(For modules where import X from 'foo.json' doesn't work under tsx/esm, use await import or read the file via node:fs — but the assertion clause stays the same.)
What to avoid
jest.mock,sinon,fetch-mock,msw— only nock. Mock at the HTTP boundary, not at the class boundary.- Mocking the module's own internal
Clientclass — that hides the bug you're trying to catch. Mock the upstream API instead. - Asserting against
typeof—expect(typeof x.id).to.equal('string')is too weak. Useexpect(x.id).to.equal('expected-id')orexpect(x.id).to.be.instanceOf(UUID). - Tests that pass without
nock.cleanAll()— they'll still pass, then suddenly fail in CI when test order changes. Add the cleanup.
Validation
ls test/unit/*.test.ts >/dev/null && echo "✓ unit tests present"
grep -hn "import nock" test/unit/*.test.ts
# Each match should be `import nock from 'nock';` — no `* as nock`.
grep -hnE "from '\.\.?\/[^']*'" test/unit/ | grep -v "\.js'"
# Should print nothing.
grep -hnE "jest\.mock|from 'sinon'|fetch-mock|from 'msw'" test/unit/
# Should print nothing.
grep -hn "process\.env" test/unit/
# Should print nothing.
grep -hn "nock\.cleanAll" test/unit/*.test.ts
# Should print at least one match (afterEach hook).
Related
- @.claude/skills/testing-core/SKILL.md — cross-cutting principles
- @.claude/skills/nock-mocking/SKILL.md — nock-specific patterns
- @.claude/skills/test-fixtures/SKILL.md — fixture organization
- @.claude/skills/error-handling/SKILL.md — which error to throw
- @.claude/skills/gate-unit-tests/SKILL.md — Gate 4a checklist