#Unit Tests
Alepha uses Vitest as the test runner. Every alepha init scaffolds a Vitest config and a sample test file - Vitest ships embedded in alepha, so there is nothing to install.
The scaffolded config turns on Vitest's globals: true, and Alepha relies on it at runtime: that is how Alepha.create() finds beforeAll and afterAll (see Lifecycle Management). The scaffolded tsconfig.json does not declare the global types, though, so specs import what they use, as the starter spec does:
1import { describe, expect, it } from "vitest";
To write specs without that line, add "types": ["vitest/globals"] to compilerOptions in your tsconfig.json. That entry resolves from the hoisted node_modules the scaffold sets up: under a strict layout, such as pnpm's isolated linker, vitest is not visible from your project, so it fails like the import above. The examples below leave the import out for brevity.
#Setup
Every Alepha project is scaffolded with test support - no flag needed:
alepha init my-app
init writes the Vitest test block into vite.config.ts (it pins test.root to the project directory) and a starter test/dummy.spec.ts - there is no separate Vitest config file. Specs live in test/, named *.spec.ts.
Run tests with alepha test. An optional positional argument is forwarded to Vitest as a filename filter:
alepha test # All tests
alepha test auth # Only specs whose path matches "auth"
alepha test test/user.spec.ts # A single file
| Flag | Effect |
|---|---|
--config, -c |
Use another Vitest config file than vite.config.ts. |
--project, -p |
Run only these Vitest projects, comma-separated. Globs work: -p web* also picks up web:jsdom (see React Tests). |
--shard <i>/<n> |
Run one slice of the spec files, e.g. --shard 2/6 as one leg of a CI matrix. Files are split by count, not by duration. |
--coverage |
Measure coverage and write the reports below. |
See alepha test in the CLI reference.
#Coverage
--coverage measures the run and leaves two machine-readable reports in
coverage/, which is already gitignored:
alepha test --coverage
coverage/coverage-summary.json |
totals plus one entry per file, each with lines, statements, functions and branches. |
coverage/test-results.json |
the run itself: how many tests passed, failed, were skipped, and per-file timings. |
The browsable HTML report is still written beside them, and the normal test output still prints. Both are easy to lose by hand: asking Vitest for the JSON coverage reporter replaces the HTML one rather than adding to it, and asking for the JSON test reporter replaces the default one, so a CI job that spelled this itself would trade every readable failure for a file nobody reads on a green run.
The paths are fixed rather than configurable, because they are a convention that
other tools read. lore quality push finds both files without being told
where they are.
VITEST_ARGS is appended after everything above, so an explicit caller flag
still lands last on the command line.
#Lifecycle Management
Alepha.create() wires itself to Vitest's hooks in test environments (this needs globals: true). You do not need beforeAll/afterAll for lifecycle:
- Created at module or
describescope, the container is started inbeforeAlland stopped inafterAll. - Created inside an
it, it is stopped when that test finishes, butbeforeAllhas already run: callawait alepha.start()yourself, as below.
1describe("Lifecycle", () => { 2 it("should inject a service", async () => { 3 const alepha = Alepha.create().with(MyService); 4 const svc = alepha.inject(MyService); 5 await alepha.start(); 6 7 const result = await svc.doSomething(); 8 expect(result).toBe("done"); 9 });10});
#Testing Actions
Actions expose two call modes: .run() for in-process invocation and .fetch() for HTTP simulation.
1import { Alepha, z } from "alepha"; 2import { $action } from "alepha/server"; 3 4class UserController { 5 getUser = $action({ 6 method: "GET", 7 path: "/users/:id", 8 schema: { params: z.object({ id: z.uuid() }) }, 9 handler: async ({ params }) => ({ id: params.id, name: "Alice" }),10 });11}12 13describe("UserController", () => {14 it("should return user by id", async () => {15 const alepha = Alepha.create().with(UserController);16 const ctrl = alepha.inject(UserController);17 await alepha.start();18 19 const id = "0b3c3d6e-6f1a-4c3e-9d2b-8a7f5e4d3c2b";20 21 // Local call - no HTTP overhead22 const result = await ctrl.getUser.run({ params: { id } });23 expect(result.name).toBe("Alice");24 25 // HTTP simulation - goes through the full request pipeline26 const response = await ctrl.getUser.fetch({ params: { id } });27 expect(response.status).toBe(200);28 expect(response.data.name).toBe("Alice");29 });30});
#DI Substitution (No vi.mock)
Never use vi.mock() or vi.spyOn(). Alepha's dependency injection system replaces the need for traditional mocking. Use .with({ provide, use }) to swap implementations.
1const alepha = Alepha.create().with({2 provide: PaymentService,3 use: FakePaymentService,4});5 6const svc = alepha.inject(PaymentService);7// svc is now an instance of FakePaymentService
#Memory Providers
Alepha ships memory implementations for all I/O-bound services. These run in-process with no external dependencies and include test assertion helpers.
| Provider | Import | Replaces |
|---|---|---|
MemoryFileSystemProvider |
alepha/system |
File system |
MemoryShellProvider |
alepha/system |
Shell commands |
MemoryQueueProvider |
alepha/queue |
Job queues |
MemoryTopicProvider |
alepha/topic |
Pub/sub topics |
MemoryLockProvider |
alepha/lock |
Distributed locks |
MemoryCacheProvider |
alepha/cache |
Caching layer |
MemoryFileStorageProvider |
alepha/bucket |
File storage (S3, R2, etc.) |
MemoryEmailProvider |
alepha/email |
Email sending |
MemorySmsProvider |
alepha/sms |
SMS sending |
MemoryCaptchaProvider |
alepha/captcha |
Captcha verification |
MemoryPaymentProvider |
alepha/api/payments |
Payment service provider (see Payments) |
In test environments, FileSystemProvider and ShellProvider both default to
their memory implementations automatically (so do email, SMS and captcha) - file
writes and shell commands stay inside the container unless a test opts back into
the real thing:
1import { FileSystemProvider, NodeFileSystemProvider } from "alepha/system";2 3// Only when a test really needs the disk (e.g. real fixture files):4const alepha = Alepha.create().with({5 provide: FileSystemProvider,6 use: NodeFileSystemProvider,7});
#Example: File System
1import { MemoryFileSystemProvider } from "alepha/system"; 2 3const alepha = Alepha.create(); // memory file system is the test default 4 5const fs = alepha.inject(MemoryFileSystemProvider); 6 7// Run code that writes files... 8await myService.generateReport(); 9 10// Assert with built-in helpers11expect(fs.wasWritten("/path/report.txt")).toBe(true);12expect(fs.wasWrittenMatching("/path/report.txt", /summary/)).toBe(true);13expect(fs.wasDeleted("/path/temp.txt")).toBe(true);
A call is recorded only once it succeeded, so a write that threw is not
reported as written. wasWrittenMatching reads the file's final
content: a path written twice matches on the second write, which is what
you want when a generator emits a placeholder before the real thing. Use
wasEverWrittenMatching for the rarer case where an intermediate write is
the subject.
Other helpers: wasAppended(path), wasWrittenWithMode(path, mode) and
getFileContent(path). To exercise an error path, set writeFileError,
readFileError or mkdirError to an Error and every matching call throws it
until reset(), which clears files, recorded calls and injected errors.
#Example: Shell Commands
1import { MemoryShellProvider } from "alepha/system"; 2 3const alepha = Alepha.create(); // memory shell is the test default 4 5const shell = alepha.inject(MemoryShellProvider); 6 7// Run code that executes shell commands... 8await myService.installDependencies(); 9 10expect(shell.wasCalled("yarn install")).toBe(true);11 12// Structured results work too: a configured error becomes exitCode 1,13// mirroring ShellProvider.capture() on the real runtimes.14shell.errors.set("git diff --quiet", "dirty");15const result = await shell.capture("git diff --quiet");16expect(result.exitCode).toBe(1);
Canned output works the same way: shell.outputs.set(command, output), or
shell.configure({ outputs, errors, installedCommands }) to set several at
once. Every call lands in shell.calls; wasCalledMatching(regex) and
getCallsMatching(regex) match them by pattern.
#Test Data with Faker
alepha/testing/faker generates data from your Zod schemas, so fixtures follow the schema when it changes. It is built on Faker, which you install yourself:
npm install -D @faker-js/faker
Register AlephaFake and inject FakeProvider:
1import { Alepha, z } from "alepha"; 2import { AlephaFake, FakeProvider } from "alepha/testing/faker"; 3 4const userSchema = z.object({ 5 id: z.uuid(), 6 firstName: z.text(), 7 email: z.email(), 8 age: z.integer().optional(), 9});10 11const alepha = Alepha.create().with(AlephaFake);12const fake = alepha.inject(FakeProvider);13 14const user = fake.generate(userSchema);15const users = fake.generateMany(userSchema, 10);
Formats and constraints in the schema are respected, and property names refine plain strings: firstName, email or city get a matching value, not random text. Output is deterministic: each provider owns its own seeded Faker instance (seed 12345), so a spec gets the same data on every run, and two containers never reset each other's sequence.
configure() tunes the generation and returns the provider:
| Option | Default | Effect |
|---|---|---|
seed |
12345 |
Reseeds the provider. |
locale |
"en" |
Faker locale. |
optionalProbability |
0.3 |
Chance an optional field is left undefined. |
nullableProbability |
0.2 |
Chance a nullable field is null. |
defaultArrayLength |
5 |
Longest array when the schema sets no bound. |
maxArrayLength |
20 |
Cap on array length; Infinity respects maxItems alone. |
defaultRecordEntries |
{ min: 2, max: 5 } |
Number of entries in a record. |
For one-off values, the module also exports Faker itself as fake (fake.person.fullName()). See alepha/testing/faker.
#Time Travel
Code that reads time through the injected DateTimeProvider (never Date.now()) is testable by moving the clock:
1const dateTime = alepha.inject(DateTimeProvider);2dateTime.pause(); // freeze the clock3await dateTime.travel([2, "hours"]); // jump forward
travel() also resolves CronProvider waits - every $job cron in the container fires, not just the one you are testing. Assert end state, not call counts, or a second cron firing will fail an otherwise-correct test.
#Database Testing
Without a DATABASE_URL, a test container uses an in-memory SQLite database, fresh for each container.
Alepha can also run tests against real Postgres, with the alepha/orm/postgres module (AlephaOrmPostgres) registered. Under test, each container creates its own throwaway schema (test_alepha_<epoch>_<random>), runs the migrations into it, and drops it on stop; schemas older than an hour left by a crashed run are swept on the next start.
Point DATABASE_URL at your test database via the env block of vite.config.ts's test section (the scaffolded config doesn't set one):
1env: {2 DATABASE_URL: "postgres://postgres:postgres@127.0.0.1:5432/postgres",3}
#Without transactions, as on D1
Cloudflare D1 has no transactions: $transactional() runs its body in place there, and a failure halfway keeps every write made before it. The SQLite drivers used by specs do roll back, and they run transactions one at a time, so a spec on them cannot see a race or a partial write that D1 would produce.
DATABASE_TRANSACTIONS: false switches transactions off for one container. $transactional() then runs its body in place, afterCommit callbacks run at once and Repository.transaction() throws, exactly as on D1. The switch is read only when NODE_ENV is test, so it can never reach production.
To reproduce a failure halfway, substitute the collaborator that fails rather than mocking it:
1import { Alepha, AlephaError } from "alepha"; 2import { AuditService } from "alepha/api/audits"; 3 4class ThrowingAuditService extends AuditService { 5 public override async create(): Promise<never> { 6 throw new AlephaError("audit insert failed"); 7 } 8} 9 10const alepha = Alepha.create({11 env: { DATABASE_URL: "sqlite://:memory:", DATABASE_TRANSACTIONS: false },12}).with({ provide: AuditService, use: ThrowingAuditService });13 14// The action's own write is kept, whatever the audit did.
#TestProvider Pattern
To unit test protected methods on a class, create a test subclass that exposes them:
1class TestCliProvider extends CliProvider {2 public testParseFlags = this.parseFlags.bind(this);3 public testResolveCommand = this.resolveCommand.bind(this);4}5 6const alepha = Alepha.create();7const cli = alepha.inject(TestCliProvider);8const result = cli.testParseFlags(["--verbose"], flagDefs);