alepha@docs:~/framework/docs/guides/testing$
cat 1-unit-tests.md | pretty
7 min read
Last commit:

#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:

typescript
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:

bash
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:

bash
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:

bash
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 describe scope, the container is started in beforeAll and stopped in afterAll.
  • Created inside an it, it is stopped when that test finishes, but beforeAll has already run: call await alepha.start() yourself, as below.
typescript
 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.

typescript
 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.

typescript
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:

typescript
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

typescript
 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

typescript
 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:

typescript
 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:

typescript
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):

typescript
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:

typescript
 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:

typescript
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);