You know that feeling when you write tests for an Express app? Mocking req, res, setting up supertest, hoping your middleware chain doesn't break...
Alepha makes testing almost enjoyable. Almost.
Alepha integrates with Vitest. For automatic lifecycle management, you need globals: true in your vitest config:
1// vitest.config.ts2import { defineConfig } from "vitest/config";3 4export default defineConfig({5 test: {6 globals: true, // required for automatic start/stop7 },8});
With globals: true, Alepha hooks into describe and it to manage the app lifecycle automatically.
Where you create your Alepha instance matters:
1import { describe, it, expect } from "vitest"; 2import { Alepha } from "alepha"; 3 4describe("MyService", () => { 5 // alepha created at describe level = auto started before tests 6 const alepha = Alepha.create().with(MyService); 7 const service = alepha.inject(MyService); 8 9 it("should do something", async () => {10 // alepha is already started here, no need to call start()11 const result = await service.doSomething();12 expect(result).toBe("done");13 });14 15 it("should do something else", async () => {16 // still started, same instance for all tests in this describe17 const result = await service.doSomethingElse();18 expect(result).toBe("done");19 });20});21// alepha.stop() called automatically after all tests
If you create Alepha inside an it block, you need to call start() manually:
1describe("MyService", () => { 2 it("should work with manual start", async () => { 3 // alepha created inside it = manual start required 4 const alepha = Alepha.create().with(MyService); 5 const service = alepha.inject(MyService); 6 7 await alepha.start(); // required! 8 9 const result = await service.doSomething();10 expect(result).toBe("done");11 });12 // alepha.stop() still called automatically13});
Services are just classes. Test them like classes.
1import { describe, it, expect } from "vitest"; 2import { Alepha } from "alepha"; 3import { UserService } from "./UserService"; 4 5describe("UserService", () => { 6 const alepha = Alepha.create().with(UserService); 7 const service = alepha.inject(UserService); 8 9 it("should create a user", async () => {10 const user = await service.create({11 email: "test@example.com",12 name: "Test User",13 });14 15 expect(user.id).toBeDefined();16 expect(user.email).toBe("test@example.com");17 });18});
You can test actions in two ways: local (direct call) or HTTP (full request cycle).
run() 1import { describe, it, expect } from "vitest"; 2import { Alepha, t } from "alepha"; 3import { $action } from "alepha/server"; 4 5class UserApi { 6 getUser = $action({ 7 path: "/users/:id", 8 schema: { 9 params: t.object({ id: t.text() }),10 response: t.object({ id: t.text(), name: t.text() }),11 },12 handler: async ({ params }) => {13 return { id: params.id, name: "John" };14 },15 });16}17 18describe("UserApi", () => {19 const alepha = Alepha.create().with(UserApi);20 const api = alepha.inject(UserApi);21 22 it("should return user by id", async () => {23 // local call, skips HTTP layer entirely24 const user = await api.getUser.run({ params: { id: "123" } });25 expect(user.name).toBe("John");26 });27});
fetch()Sometimes you need to test the full HTTP cycle. Maybe you have security hooks, rate limiting, or middleware that only runs on real HTTP requests:
1describe("UserApi", () => { 2 const alepha = Alepha.create().with(UserApi); 3 const api = alepha.inject(UserApi); 4 5 it("should enforce auth via HTTP", async () => { 6 // forces actual HTTP request through the full middleware chain 7 const response = await api.getUser.fetch({ params: { id: "123" } }); 8 9 // now you can test HTTP-specific behavior10 expect(response.status).toBe(401); // auth required11 });12 13 it("should work with valid token", async () => {14 const response = await api.getUser.fetch({15 params: { id: "123" },16 headers: { Authorization: "Bearer valid-token" },17 });18 19 expect(response.status).toBe(200);20 });21});
Use run() for most tests - it's faster and gives clearer errors. Use fetch() when you specifically need to test HTTP-level behavior like authentication hooks, CORS, rate limiting, or response headers.
When testing with PostgreSQL, Alepha creates an isolated schema for each test file. The schema name is derived from PG_TEST_SCHEMA or generated automatically. After tests complete, the schema is destroyed.
This means:
1import { describe, it, expect } from "vitest"; 2import { Alepha, t } from "alepha"; 3import { $entity, $repository, db } from "alepha/orm"; 4 5const userEntity = $entity({ 6 name: "users", 7 schema: t.object({ 8 id: db.primaryKey(), 9 email: t.email(),10 }),11});12 13class Db {14 users = $repository(userEntity);15}16 17describe("UserRepository", () => {18 const alepha = Alepha.create().with(Db);19 const db = alepha.inject(Db);20 21 it("should create and find user", async () => {22 const user = await db.users.create({ email: "test@test.com" });23 expect(user.id).toBeDefined();24 25 const found = await db.users.findById(user.id);26 expect(found?.email).toBe("test@test.com");27 });28 29 it("should list users", async () => {30 // this runs in the same schema, sees data from previous test31 const users = await db.users.findMany();32 expect(users.length).toBeGreaterThan(0);33 });34});35// schema destroyed after all tests in this file
Here's something important: all external providers use in-memory implementations during tests.
$queue → MemoryQueueProvider$topic → MemoryTopicProvider$email → MemoryEmailProvider$sms → MemorySmsProvider$cache → MemoryCacheProvider$lock → MemoryLockProviderNo fear of accidentally sending real emails or SMS during tests. Everything stays in memory.
1import { describe, it, expect } from "vitest"; 2import { Alepha } from "alepha"; 3import { MemoryEmailProvider } from "alepha/email"; 4 5describe("SignupService", () => { 6 const alepha = Alepha.create().with(SignupService); 7 const service = alepha.inject(SignupService); 8 const emailProvider = alepha.inject(MemoryEmailProvider); 9 10 it("should send welcome email", async () => {11 await service.signup({ email: "new@user.com" });12 13 // check in-memory store14 const sent = emailProvider.sent;15 expect(sent).toHaveLength(1);16 expect(sent[0].to).toBe("new@user.com");17 expect(sent[0].subject).toContain("Welcome");18 });19});
Same pattern works for SMS:
1import { MemorySmsProvider } from "alepha/sms";2 3const smsProvider = alepha.inject(MemorySmsProvider);4expect(smsProvider.sent[0].to).toBe("+1234567890");
Alepha's .with({ provide, use }) is the recommended way to mock dependencies. It's explicit, type-safe, and doesn't rely on module magic.
1import { describe, it, expect } from "vitest"; 2import { Alepha } from "alepha"; 3 4class PaymentGateway { 5 async charge(amount: number): Promise<string> { 6 // real Stripe call 7 return "ch_real_charge_id"; 8 } 9}10 11class MockPaymentGateway extends PaymentGateway {12 charges: number[] = [];13 14 async charge(amount: number): Promise<string> {15 this.charges.push(amount);16 return "ch_mock_charge_id";17 }18}19 20describe("OrderService", () => {21 const alepha = Alepha.create()22 .with(OrderService)23 .with({ provide: PaymentGateway, use: MockPaymentGateway });24 25 const orderService = alepha.inject(OrderService);26 const mockPayment = alepha.inject(MockPaymentGateway);27 28 it("should charge the correct amount", async () => {29 await orderService.checkout({ total: 99.99 });30 31 expect(mockPayment.charges).toContain(99.99);32 });33});
Module mocking (vi.mock()) is discouraged. It's brittle, hard to type, and fights against Alepha's dependency injection.
Spies (vi.spyOn()) are fine when you need to verify a method was called without replacing the implementation:
1import { vi, describe, it, expect } from "vitest"; 2 3describe("AuditService", () => { 4 const alepha = Alepha.create().with(AuditService); 5 const service = alepha.inject(AuditService); 6 7 it("should log audit events", async () => { 8 const spy = vi.spyOn(service, "log"); 9 10 await service.recordAction("user_login", { userId: "123" });11 12 expect(spy).toHaveBeenCalledWith("user_login", { userId: "123" });13 });14});
But prefer service substitution when possible. It's more explicit about what you're testing.
Use FakeProvider to generate realistic test data. Always inject it, never use new:
1import { describe, it, expect } from "vitest"; 2import { Alepha, t } from "alepha"; 3import { FakeProvider } from "alepha/fake"; 4 5describe("FakeProvider", () => { 6 const alepha = Alepha.create(); 7 const fake = alepha.inject(FakeProvider); // inject, don't use new! 8 9 it("should generate valid user data", () => {10 const userSchema = t.object({11 id: t.uuid(),12 email: t.email(),13 firstName: t.text(),14 age: t.integer({ minimum: 18, maximum: 99 }),15 });16 17 const user = fake.generate(userSchema);18 19 // uses property names as hints20 // email → generates email21 // firstName → generates first name22 expect(user.email).toContain("@");23 expect(user.age).toBeGreaterThanOrEqual(18);24 });25});
We recommend this structure:
src/
users/
UserService.ts
UserApi.ts
test/
users/
UserService.spec.ts # unit tests
UserApi.spec.ts # action tests
integration/
signup-flow.spec.ts # full flow tests
Name your test files .spec.ts. For browser tests (jsdom), use .browser.spec.ts - see Vitest documentation for configuration details.
1import { describe, it, expect } from "vitest"; 2import { Alepha } from "alepha"; 3 4describe("UserService", () => { 5 const alepha = Alepha.create().with(UserService); 6 const service = alepha.inject(UserService); 7 8 it("should deactivate user", async () => { 9 // arrange10 const user = await service.create({ email: "test@test.com" });11 12 // act13 await service.deactivate(user.id);14 15 // assert16 const updated = await service.findById(user.id);17 expect(updated?.active).toBe(false);18 });19});
1import { describe, it, expect } from "vitest"; 2import { Alepha } from "alepha"; 3 4describe("UserService", () => { 5 const alepha = Alepha.create().with(UserService); 6 const service = alepha.inject(UserService); 7 8 it("should throw on invalid email", async () => { 9 await expect(10 service.create({ email: "not-an-email" })11 ).rejects.toThrow("Invalid email");12 });13});
globals: true - enables automatic lifecycle managementstart() calls.with() for mocking - explicit and type-saferun() for most tests - faster than HTTPfetch() for HTTP-specific tests - security hooks, headers, etc.new FakeProvider()vi.mock() - use service substitution insteadTesting with Alepha isn't painful. It's just... testing. The way it should be.