Skip to main content

Chapter 31: Unit Testing with Vitest

Vitest is the natural testing framework for Vite projects — it shares your Vite config, understands your aliases, and runs at native ES module speed.

Configuration

Configuration lives in vite.config.ts (shared with the build):

// vite.config.ts (test section)
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.*", "src/test/**", "src/route-tree.gen.ts"],
thresholds: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
},

Testing Effect Services

The real payoff of dependency injection: test business logic by providing mock layers.

// features/projects/application/use-cases/__tests__/create-project.test.ts
import { describe, it, expect, vi } from "vitest";
import { Effect, Layer, Exit } from "effect";
import { createProject } from "../create-project";
import { ProjectRepository } from "../../../domain/ports/project-repository";
import { AuthorizationService } from "@/features/auth/services/authorization-service";
import { AuditService } from "@/shared/services/audit-service";
import { ProjectNameTakenError } from "../../../domain/errors/project-errors";

// Mock layers
const mockProject = {
id: "proj-1",
name: "Test Project",
status: "active",
organizationId: "org-1",
createdById: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
};

const TestProjectRepo = Layer.succeed(ProjectRepository, {
findByName: () => Effect.succeed(null),
create: (data) => Effect.succeed({ ...mockProject, ...data }),
findById: () => Effect.succeed(mockProject),
findByOrganization: () => Effect.succeed([]),
update: () => Effect.succeed(mockProject),
softDelete: () => Effect.void,
count: () => Effect.succeed(0),
});

const TestAuthz = Layer.succeed(AuthorizationService, {
requirePermission: () => Effect.void,
checkPermission: () => Effect.succeed(true),
getUserRole: () => Effect.succeed("ADMIN"),
});

const TestAudit = Layer.succeed(AuditService, {
record: () => Effect.void,
});

const TestLayer = Layer.mergeAll(TestProjectRepo, TestAuthz, TestAudit);

describe("createProject", () => {
it("creates a project successfully", async () => {
const result = await Effect.runPromise(
createProject(
{ name: "New Project", organizationId: "org-1" },
"user-1"
).pipe(Effect.provide(TestLayer))
);

expect(result.name).toBe("New Project");
});

it("fails when project name is taken", async () => {
const RepoWithExisting = Layer.succeed(ProjectRepository, {
...Layer.build(TestProjectRepo), // Hmm, we need a different approach
findByName: () => Effect.succeed(mockProject),
create: () => Effect.die("should not be called"),
findById: () => Effect.succeed(mockProject),
findByOrganization: () => Effect.succeed([]),
update: () => Effect.succeed(mockProject),
softDelete: () => Effect.void,
count: () => Effect.succeed(0),
});

const TestLayerWithExisting = Layer.mergeAll(
RepoWithExisting,
TestAuthz,
TestAudit,
);

const exit = await Effect.runPromiseExit(
createProject(
{ name: "Existing Project", organizationId: "org-1" },
"user-1"
).pipe(Effect.provide(TestLayerWithExisting))
);

expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const error = exit.cause;
// Verify it's the right kind of failure
expect(String(error)).toContain("ProjectNameTaken");
}
});

it("checks authorization before creating", async () => {
const UnauthorizedAuthz = Layer.succeed(AuthorizationService, {
requirePermission: () =>
Effect.fail(new InsufficientPermissionsError({
userId: "user-1",
permission: "project:create",
organizationId: "org-1",
})),
checkPermission: () => Effect.succeed(false),
getUserRole: () => Effect.succeed("VIEWER"),
});

const TestLayerUnauthorized = Layer.mergeAll(
TestProjectRepo,
UnauthorizedAuthz,
TestAudit,
);

const exit = await Effect.runPromiseExit(
createProject(
{ name: "New Project", organizationId: "org-1" },
"user-1"
).pipe(Effect.provide(TestLayerUnauthorized))
);

expect(Exit.isFailure(exit)).toBe(true);
});
});

Testing Pure Functions

// domain/rules/__tests__/project-rules.test.ts
import { describe, it, expect } from "vitest";
import { canUserEditProject, calculateProjectProgress } from "../project-rules";

describe("canUserEditProject", () => {
it("allows owner to edit", () => {
expect(canUserEditProject("OWNER", "active")).toBe(true);
});

it("prevents viewer from editing", () => {
expect(canUserEditProject("VIEWER", "active")).toBe(false);
});

it("prevents editing archived projects", () => {
expect(canUserEditProject("ADMIN", "archived")).toBe(false);
});
});

describe("calculateProjectProgress", () => {
it("returns 0 for no tasks", () => {
expect(calculateProjectProgress([])).toBe(0);
});

it("returns 100 when all tasks are done", () => {
const tasks = [
{ status: "DONE" },
{ status: "DONE" },
];
expect(calculateProjectProgress(tasks)).toBe(100);
});

it("calculates percentage correctly", () => {
const tasks = [
{ status: "DONE" },
{ status: "TODO" },
{ status: "IN_PROGRESS" },
{ status: "DONE" },
];
expect(calculateProjectProgress(tasks)).toBe(50);
});
});

Testing Schema Validation

// features/projects/schemas/__tests__/project-schemas.test.ts
import { describe, it, expect } from "vitest";
import { Schema, Either } from "effect";
import { CreateProjectSchema } from "../project-schemas";

describe("CreateProjectSchema", () => {
const decode = Schema.decodeUnknownEither(CreateProjectSchema);

it("accepts valid input", () => {
const result = decode({
name: "Test Project",
priority: "high",
});
expect(Either.isRight(result)).toBe(true);
});

it("rejects empty name", () => {
const result = decode({ name: "" });
expect(Either.isLeft(result)).toBe(true);
});

it("rejects name over 100 characters", () => {
const result = decode({ name: "x".repeat(101) });
expect(Either.isLeft(result)).toBe(true);
});

it("applies default priority", () => {
const result = decode({ name: "Test" });
expect(Either.isRight(result)).toBe(true);
if (Either.isRight(result)) {
expect(result.right.priority).toBe("medium");
}
});
});

Running Tests

pnpm test              # Watch mode (development)
pnpm test:run # Single run (CI)
pnpm test:coverage # With coverage report
pnpm test:ui # Visual UI for exploring tests

Summary

  • Vitest shares Vite config — aliases, plugins, and module resolution work identically
  • Effect services tested with mock layers — no database or network needed
  • Pure functions tested directly with simple assertions
  • Schema validation tested with decodeUnknownEither
  • Coverage thresholds enforce minimum test coverage