Console Test Writing Guide
Testing conventions and patterns for the akash-network/console monorepo.
Deciding What Type of Test to Write
Choose the lowest-effective test level:
| Level | When to use in this repo | Mocking strategy |
|---|---|---|
| Unit (default) | Components, hooks, services, utilities | All deps mocked via DI (mock<T>()) — never vi.mock() |
| Integration | Services with heavy DB logic or Repository patterns; required for every pg-boss job handler | Real DB fixtures; mock only 3rd-party calls |
| Functional | Black-box HTTP endpoint verification | nock for external calls only; never mock internal services |
| E2E | Post-deployment verification (beta/prod) | No mocks; happy path only; Playwright with semantic locators |
Functional tests should only fail when functional requirements change, not from refactoring. Don't write functional tests for simple routes — test the service layer directly.
Universal Rules (All Test Types)
The setup Function Pattern
This is the most important convention. Never use beforeEach with shared mutable variables.
- Define a
setup()function at the bottom of the rootdescribeblock - It constructs the unit under test and returns it along with all mocked dependencies
- It accepts a single parameter with an inline type definition
- Do not specify the return type — let TypeScript infer it
- Each test calls
setup()independently — no shared mutable state across tests
describe(BalancesService.name, () => {
it("returns balances for a given address", async () => {
const { service, balanceRepository } = setup();
balanceRepository.findByAddress.mockResolvedValue([{ denom: "uakt", amount: "1000" }]);
const result = await service.getBalances("akash1abc...");
expect(result).toEqual([{ denom: "uakt", amount: "1000" }]);
});
it("returns empty array when no balances found", async () => {
const { service, balanceRepository } = setup();
balanceRepository.findByAddress.mockResolvedValue([]);
const result = await service.getBalances("akash1xyz...");
expect(result).toEqual([]);
});
function setup(input?: { customConfig?: Partial<BalancesConfig> }) {
const balanceRepository = mock<BalanceRepository>();
const config = mockConfigService<BalancesConfig>({ ...input?.customConfig });
const service = new BalancesService(balanceRepository, config);
return { service, balanceRepository, config };
}
});
Skip setup only if creating the object under test is trivially simple (e.g., testing a pure function with no dependencies).
Test Description Conventions
Root describe: Use SubjectUnderTest.name (the actual reference, not a string). This enables IDE refactoring and reference-finding.
// Good
describe(StripeWebhookService.name, () => { ... });
// Bad
describe("StripeWebhookService", () => { ... });
Nested describe: Use either a method name or a "when ..." condition.
describe(StripeWebhookService.name, () => {
describe("handleChargeRefunded", () => { ... });
describe("when payment method is missing", () => { ... });
});
it blocks: Use present simple, 3rd person singular. Do NOT prepend with "should".
// Good
it("returns early when customer ID is missing", async () => { ... });
it("updates both limits and isTrialing when endTrial is true", async () => { ... });
// Bad
it("should return early when customer ID is missing", async () => { ... });
Mocking: mock<T>() from vitest-mock-extended
Always use mock<T>() for typed mocks. Never use jest.mock() or vi.mock() for module-level mocking — it causes OOM with heavy component trees and couples tests to implementation details.
import { mock } from "vitest-mock-extended";
const userRepository = mock<UserRepository>();
const stripeService = mock<StripeService>();
const logger = mock<LoggerService>();
Prefer mock<T>() over as unknown as <Type> for hand-built doubles. When stubbing a hook return value or service that has many fields, build it with mock<T>() and override only the fields the test cares about. Hand-built objects coerced through as unknown as silently survive contract changes upstream and hide real regressions.
// Good — stays in sync with the real type, lets the test focus on what matters
const useCustomUser: typeof DEPENDENCIES.useCustomUser = () =>
mock<ReturnType<typeof DEPENDENCIES.useCustomUser>>({
user: { username: "alice" },
isLoading: false
});
// Bad — `UseCustomUser` could grow a required field and this still compiles
const useCustomUser = () =>
({ user: { username: "alice" }, isLoading: false }) as unknown as ReturnType<typeof DEPENDENCIES.useCustomUser>;
Reserve as unknown as for the rare case where the type is structurally incompatible (e.g. branded types) and mock<T>() cannot satisfy it.
For config services, use mockConfigService<T>() (in apps/api/test/mocks/config-service.mock.ts):
const billingConfig = mockConfigService<BillingConfigService>({
DEPLOYMENT_GRANT_DENOM: "uakt",
TRIAL_ALLOWANCE_EXPIRATION_DAYS: 14
});
Vitest Imports
Always explicitly import from vitest:
import { describe, expect, it, vi } from "vitest";
Explicit imports make dependencies visible and simplify TypeScript types.
No if Statements in Assertions
Never use conditional logic in test expectations. If you need to narrow a type, use as assertions. Otherwise, skipped assertions silently pass when they shouldn't.
// Good
const result = await service.validate(cert) as CertValidationResultError;
expect(result.ok).toBe(false);
expect(result.code).toBe("unknownCertificate");
// Bad
const result = await service.validate(cert);
if (!result.ok) {
expect(result.code).toBe("unknownCertificate");
}
Arrange-Act-Assert (AAA)
Follow the AAA pattern. Setup prepares state; the test validates it. Don't mix concerns.
it("creates a deployment grant", async () => {
// Arrange
const { service, grantService } = setup();
grantService.create.mockResolvedValue(mockGrant);
// Act
const result = await service.createGrant("akash1abc...");
// Assert
expect(result).toEqual(mockGrant);
expect(grantService.create).toHaveBeenCalledWith("akash1abc...");
});
Test Error Paths the Code Handles
If a service catches and transforms errors, test those paths. Don't skip error coverage just because the happy path works — and flag unhandled error scenarios rather than ignoring them.
Frontend Tests (deploy-web)
Read @references/frontend-patterns.md for the full set of frontend-specific patterns including the DEPENDENCIES pattern, hook testing, query testing, and container testing.
Key points:
- Use
getBy*for presence assertions (toBeInTheDocument()), andqueryBy*for absence assertions (not.toBeInTheDocument()) - Use the
DEPENDENCIESexport +dependenciesprop for component DI (nevervi.mock) — this covers components, hooks, and any other heavy imports - Use
MockComponents()helper to auto-mock dependencies - Services are injected via
useServiceshook, not viaDEPENDENCIESprop - Use
setupQuery()utility for React Query hook tests - Use
renderHookfrom@testing-library/reactfor hook tests
API Unit Tests
Read @references/api-patterns.md for the full set of API-specific patterns including service testing, config mocking, and seeder patterns.
Key points:
- Construct services manually with
mock<T>()dependencies - Use seeders for test data (
apps/api/test/seeders/) - Use function-based seeders (not class-based)
- Use
@faker-js/fakerfor randomized data in seeders - Seeder accepts
Partial<T>overrides with sensible defaults
Background Job Handlers (apps/api)
A pg-boss job handler needs an integration test (*.handler.integration.ts), not a unit spec. A unit spec cannot cover it, because the two ways a job fails in production are both invisible to mocks:
- the worker installs a fake system user and an empty CASL ability, so any ability-scoped repository call throws
ForbiddenError— the job then dies after its retries with no user-facing error - a query that matches no rows reports success while doing nothing
Mocked repositories consult no ability and return whatever the test told them to, so they cannot produce either outcome.
Rules:
- Run the handler through the real worker —
useJobWorkers+startWorkers, neverhandler.handle(payload)alone. Callinghandledirectly bypasses the worker context and tests an execution environment that does not exist in production. - Assert on end state (rows, job rows), never on
expect(repo.method).toHaveBeenCalled(). A write that matched zero rows satisfies the spy and fails the row read. - Wait with
expectJobCompleted(jobName)rather than only waiting on a side effect, so a refused job reports its cause instead of timing out opaquely. - Fake collaborators at the tsyringe boundary with
vi.spyOn(container.resolve(X), "m"), or withnockfor HTTP.test:ci-setupstarts only the database and the mock OAuth2 server, so anything reaching notifications, provider-proxy, the tx signer, or KMS must be faked or the test dies onECONNREFUSED. - Keep the existing unit spec. Integration coverage supersedes it as the guarantee, but removing specs is separate work with its own coverage argument.
The shared helpers live in apps/api/test/services/job-queue-harness.ts and the DB seeders in apps/api/test/seeders/db/. apps/api/src/deployment/services/delete-unbacked-deployment-setting/delete-unbacked-deployment-setting.handler.integration.ts is the reference example.
An exception is fair for a handler that touches no table and makes no ability-scoped call — a pure pass-through to another service. Such a test asserts a spy was called after a queue round trip, which tests the harness rather than the handler. Say so rather than writing it.
API Functional Tests
Read @references/api-patterns.md for functional test setup details.
Key points:
- Whitebox seeding, blackbox testing: Seed data at the DB/repository level, but interact only through HTTP
- Each spec file gets its own database via
TestDatabaseService(auto-created, migrated, dropped) - Don't resolve controllers/services from the DI container — don't call application services or other endpoints to set up state
- Mock external HTTP calls with
nock, not internal services - Use a plain HTTP client (
fetch()-based helpers) for Hono apps,supertestfor NestJS — avoid framework-internal methods likeapp.request() - Each test verifies a single endpoint's behavior in isolation
- Use function-based seeders to create test fixtures in the real database
- Write race condition tests for upsert operations
Notifications Tests (NestJS)
- Use
@nestjs/testingTest.createTestingModule()for DI - Use
MockProvider()helper to create NestJS providers withvitest-mock-extendedmocks - Functional tests use
supertestwith a real NestJS app and per-file test databases
E2E / Playwright Tests
- Use semantic locators:
getByRole,getByLabel,getByPlaceholder— never CSS selectors ordata-testid getByRole('button', { name: /Submit/ })— don't add redundantaria-labelto buttons that already have text- Page Objects abstract UI interactions but must NOT contain assertions
- Use
waitFor(...)instead ofsetTimeout— timeouts cause flakiness - No
console.logor manual screenshots — use Playwright's built-in traces (npx playwright --ui) - No randomness in test data — it causes flaky failures
- Use
Promise.allwithcontext.waitForEvent("page")for navigation patterns
Test File Organization
- Test files are co-located with source:
my-service.ts→my-service.spec.ts - Frontend:
*.spec.tsxfor components,*.spec.tsfor logic - API unit:
src/**/*.spec.ts - API integration:
src/**/*.integration.ts - API functional:
test/functional/**/*.spec.ts - API e2e:
test/e2e/**/*.spec.ts - Seeders:
test/seeders/in each app
After Writing Tests
Verify before committing:
- Run the test suite in the affected app:
npm test(ornpm run test:unit/npm run test:functionaldepending on scope) - Check for type errors:
npx tsc --noEmitin the app directory - Run the linter:
npm run lint -- --quiet - Review test output — ensure no skipped tests, no unexpected console warnings, and all assertions are meaningful (not just
expect(true).toBe(true)) - Verify coverage — the new test should cover the behavior described in the issue or PR, not just hit lines