Testing Philosophy: xUnit/NUnit vs. Jest/Vitest
For .NET engineers who know: xUnit, NUnit, Moq, test projects,
[Fact],[Theory],FluentAssertionsYou’ll learn: How JS/TS testing maps to your existing mental model — and the one place it genuinely has no equivalent Time: 15-20 min read
The .NET Way (What You Already Know)
In .NET, tests live in separate projects. Your OrderService in MyApp.Core gets a companion MyApp.Core.Tests project, with a <ProjectReference> pointing back to production code. The test runner discovers tests through attributes: [Fact] marks a single test, [Theory] with [InlineData] parameterizes it. Setup and teardown are handled via the constructor (runs before each test) and IDisposable.Dispose() (runs after). For shared setup across a class, IClassFixture<T> gives you a single instance; for shared state across multiple classes, ICollectionFixture<T> coordinates it.
Mocking is a separate library concern. Moq intercepts interfaces, letting you stub return values with Setup() and verify call behavior with Verify(). The test project installs Moq via NuGet; production code never touches it.
// MyApp.Core.Tests/OrderServiceTests.cs
public class OrderServiceTests : IDisposable
{
private readonly Mock<IOrderRepository> _mockRepo;
private readonly Mock<IEmailService> _emailService;
private readonly OrderService _sut;
public OrderServiceTests()
{
_mockRepo = new Mock<IOrderRepository>();
_emailService = new Mock<IEmailService>();
_sut = new OrderService(_mockRepo.Object, _emailService.Object);
}
[Fact]
public async Task PlaceOrder_ValidOrder_ReturnsOrderId()
{
// Arrange
var order = new Order { ProductId = 1, Quantity = 2 };
_mockRepo.Setup(r => r.SaveAsync(order)).ReturnsAsync(42);
// Act
var result = await _sut.PlaceOrderAsync(order);
// Assert
Assert.Equal(42, result);
_emailService.Verify(e => e.SendConfirmationAsync(42), Times.Once);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public async Task PlaceOrder_InvalidQuantity_Throws(int quantity)
{
var order = new Order { ProductId = 1, Quantity = quantity };
await Assert.ThrowsAsync<ArgumentException>(() => _sut.PlaceOrderAsync(order));
}
public void Dispose()
{
// cleanup if needed
}
}
Coverage is measured by dotnet test --collect:"XPlat Code Coverage", reported as Cobertura XML, and visualized in Azure DevOps or via ReportGenerator. The CI pipeline is explicit: build the test project, run it, fail if coverage drops below threshold.
The Vitest Way
Test File Location: No Separate Project
The first thing to internalize: there is no test project. Tests live alongside the code they test.
src/
services/
order.service.ts ← production code
order.service.test.ts ← tests, right here
order.service.spec.ts ← also valid, same thing
Both *.test.ts and *.spec.ts are valid conventions — spec comes from the BDD world, test is more common in utility code. Pick one per project and be consistent. We use *.test.ts for unit tests and *.spec.ts for integration tests to make them visually distinct in large codebases.
Vitest finds test files by scanning for these patterns automatically — no <ProjectReference>, no build target, no separate csproj. The tradeoff: test code is closer to production code (faster feedback loop, easier navigation), but you need your build process to exclude test files from production bundles. Vitest handles this; the TypeScript compiler is typically configured to ignore *.test.ts files in tsconfig.app.json while tsconfig.test.json includes them.
The describe/it/expect API
Where xUnit uses class structure to group tests, Vitest uses describe blocks. Where [Fact] marks a test, it (or test — they’re identical) marks one. Where xUnit has assertion methods on Assert.*, Vitest chains expectations from expect().
| xUnit/NUnit | Vitest equivalent |
|---|---|
| Test class | describe() block |
[Fact] / [Test] | it() or test() |
[Theory] / [InlineData] | it.each() / test.each() |
Assert.Equal(expected, actual) | expect(actual).toBe(expected) |
Assert.True(expr) | expect(expr).toBe(true) |
Assert.ThrowsAsync<T>() | await expect(fn()).rejects.toThrow() |
Assert.NotNull(obj) | expect(obj).not.toBeNull() |
Assert.Contains(item, collection) | expect(collection).toContain(item) |
FluentAssertions: result.Should().BeEquivalentTo(expected) | expect(result).toEqual(expected) |
One note on toBe vs. toEqual: toBe uses Object.is — reference equality, like ReferenceEquals() in C#. toEqual does a deep structural comparison, like Assert.Equivalent() in xUnit or Should().BeEquivalentTo() in FluentAssertions. You will almost always want toEqual for objects, and toBe for primitives.
Setup and Teardown
Constructor/Dispose maps directly to beforeEach/afterEach. The scoping works the same way: beforeEach inside a describe block runs before each test in that block only.
| .NET xUnit lifecycle | Vitest equivalent |
|---|---|
| Constructor | beforeEach() |
IDisposable.Dispose() | afterEach() |
IClassFixture<T> (once per class) | beforeAll() / afterAll() |
ICollectionFixture<T> (once per collection) | beforeAll() in outer describe |
// setup/teardown mapping
describe("OrderService", () => {
let sut: OrderService;
let mockRepo: MockedObject<OrderRepository>;
beforeEach(() => {
// runs before each test — same as xUnit constructor
mockRepo = createMockRepo();
sut = new OrderService(mockRepo);
});
afterEach(() => {
// runs after each test — same as Dispose()
vi.restoreAllMocks();
});
beforeAll(() => {
// runs once before all tests in this describe — same as IClassFixture
});
afterAll(() => {
// runs once after all tests in this describe
});
});
Mocking: Built-In, No Separate Library
Moq requires an interface, a mock object, and a setup call per method. Vitest’s mocking is built into the framework and works differently: you mock at the module level, not the interface level.
vi.fn() creates a mock function (equivalent to Mock<IService>().Setup(s => s.Method())):
const mockSave = vi.fn().mockResolvedValue(42);
vi.spyOn() wraps an existing method on an object, letting you track calls without replacing the implementation (unless you want to). Equivalent to Moq’s CallBase behavior:
const spy = vi.spyOn(emailService, 'sendConfirmation').mockResolvedValue(undefined);
vi.mock() replaces an entire module — no equivalent in the .NET world because .NET doesn’t have a module system at that level. More on this in the Gotchas section.
Verifying calls uses the mock’s own properties rather than a separate Verify() call:
// Moq: _emailService.Verify(e => e.SendConfirmationAsync(42), Times.Once);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(42);
// Or, checking the most recent call's arguments
expect(spy).toHaveBeenLastCalledWith(42);
Parameterized Tests: it.each
[Theory] + [InlineData] maps to it.each. Two syntaxes:
// Array of arrays — each inner array is one test case
it.each([
[0, "zero"],
[-1, "negative"],
[-999, "large negative"],
])("rejects quantity %i (%s)", async (quantity, _description) => {
await expect(sut.placeOrder({ productId: 1, quantity }))
.rejects.toThrow(ValidationError);
});
// Tagged template literal — more readable for named params
it.each`
quantity | description
${0} | ${"zero"}
${-1} | ${"negative"}
`("rejects $description quantity", async ({ quantity }) => {
await expect(sut.placeOrder({ productId: 1, quantity }))
.rejects.toThrow(ValidationError);
});
Side-by-Side: Testing the Same Service
Here is the same OrderService tested in both languages, demonstrating every concept above in parallel.
The service under test
// C# — OrderService.cs
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailService _emailService;
public OrderService(IOrderRepository repository, IEmailService emailService)
{
_repository = repository;
_emailService = emailService;
}
public async Task<int> PlaceOrderAsync(Order order)
{
if (order.Quantity <= 0)
throw new ArgumentException("Quantity must be positive", nameof(order));
var orderId = await _repository.SaveAsync(order);
await _emailService.SendConfirmationAsync(orderId);
return orderId;
}
public async Task<IReadOnlyList<Order>> GetOrdersAsync(int userId)
{
return await _repository.GetByUserAsync(userId);
}
}
// TypeScript — order.service.ts
export class OrderService {
constructor(
private readonly repository: OrderRepository,
private readonly emailService: EmailService,
) {}
async placeOrder(order: Order): Promise<number> {
if (order.quantity <= 0) {
throw new ValidationError("Quantity must be positive");
}
const orderId = await this.repository.save(order);
await this.emailService.sendConfirmation(orderId);
return orderId;
}
async getOrders(userId: number): Promise<Order[]> {
return this.repository.getByUser(userId);
}
}
The tests
// C# — OrderServiceTests.cs (xUnit + Moq)
public class OrderServiceTests : IDisposable
{
private readonly Mock<IOrderRepository> _mockRepo;
private readonly Mock<IEmailService> _mockEmail;
private readonly OrderService _sut;
public OrderServiceTests()
{
_mockRepo = new Mock<IOrderRepository>();
_mockEmail = new Mock<IEmailService>();
_sut = new OrderService(_mockRepo.Object, _mockEmail.Object);
}
// --- PlaceOrder tests ---
[Fact]
public async Task PlaceOrder_ValidOrder_SavesAndSendsEmail()
{
// Arrange
var order = new Order { ProductId = 1, Quantity = 3 };
_mockRepo.Setup(r => r.SaveAsync(order)).ReturnsAsync(99);
_mockEmail.Setup(e => e.SendConfirmationAsync(99)).Returns(Task.CompletedTask);
// Act
var result = await _sut.PlaceOrderAsync(order);
// Assert
Assert.Equal(99, result);
_mockEmail.Verify(e => e.SendConfirmationAsync(99), Times.Once);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-100)]
public async Task PlaceOrder_InvalidQuantity_ThrowsArgumentException(int quantity)
{
var order = new Order { ProductId = 1, Quantity = quantity };
var ex = await Assert.ThrowsAsync<ArgumentException>(
() => _sut.PlaceOrderAsync(order)
);
Assert.Contains("Quantity", ex.Message);
// Verify nothing was saved
_mockRepo.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Never);
}
// --- GetOrders tests ---
[Fact]
public async Task GetOrders_ReturnsUserOrders()
{
// Arrange
var expected = new List<Order>
{
new() { Id = 1, UserId = 5 },
new() { Id = 2, UserId = 5 },
};
_mockRepo.Setup(r => r.GetByUserAsync(5)).ReturnsAsync(expected);
// Act
var result = await _sut.GetOrdersAsync(5);
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, o => Assert.Equal(5, o.UserId));
}
public void Dispose()
{
// nothing to dispose in this test, but the pattern is here
}
}
// TypeScript — order.service.test.ts (Vitest)
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { OrderService } from "./order.service";
import { ValidationError } from "../errors";
import type { OrderRepository } from "./order.repository";
import type { EmailService } from "../email/email.service";
describe("OrderService", () => {
let sut: OrderService;
let mockRepo: { save: ReturnType<typeof vi.fn>; getByUser: ReturnType<typeof vi.fn> };
let mockEmail: { sendConfirmation: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockRepo = {
save: vi.fn(),
getByUser: vi.fn(),
};
mockEmail = {
sendConfirmation: vi.fn(),
};
sut = new OrderService(
mockRepo as unknown as OrderRepository,
mockEmail as unknown as EmailService,
);
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- placeOrder tests ---
describe("placeOrder", () => {
it("saves the order and sends a confirmation email", async () => {
// Arrange
const order = { productId: 1, quantity: 3 };
mockRepo.save.mockResolvedValue(99);
mockEmail.sendConfirmation.mockResolvedValue(undefined);
// Act
const result = await sut.placeOrder(order);
// Assert
expect(result).toBe(99);
expect(mockEmail.sendConfirmation).toHaveBeenCalledTimes(1);
expect(mockEmail.sendConfirmation).toHaveBeenCalledWith(99);
});
it.each([
[0],
[-1],
[-100],
])("throws ValidationError for quantity %i", async (quantity) => {
const order = { productId: 1, quantity };
await expect(sut.placeOrder(order)).rejects.toThrow(ValidationError);
await expect(sut.placeOrder(order)).rejects.toThrow("Quantity must be positive");
// Verify nothing was saved
expect(mockRepo.save).not.toHaveBeenCalled();
});
});
// --- getOrders tests ---
describe("getOrders", () => {
it("returns orders for the given user", async () => {
// Arrange
const expected = [
{ id: 1, userId: 5 },
{ id: 2, userId: 5 },
];
mockRepo.getByUser.mockResolvedValue(expected);
// Act
const result = await sut.getOrders(5);
// Assert
expect(result).toHaveLength(2);
expect(result.every((o) => o.userId === 5)).toBe(true);
});
});
});
Snapshot Testing (No .NET Equivalent)
Snapshot testing is the one Vitest feature with no .NET analog. It serializes the output of a function — usually a rendered UI component, but any serializable value works — to a .snap file on first run. Subsequent runs compare against the saved snapshot and fail if anything changed.
// First run: creates __snapshots__/api-response.test.ts.snap
it("returns the expected order shape", async () => {
const result = await sut.placeOrder({ productId: 1, quantity: 2 });
expect(result).toMatchSnapshot();
});
// If you change the return shape, the test fails with a diff.
// To accept the new output as correct: vitest --update-snapshots
Snapshots are most valuable for:
- UI component output (rendered HTML/JSX)
- API response shapes you want to detect accidental changes to
- Complex serialized structures where writing
toEqualassertions would be tedious
The downside: snapshots can become a rubber stamp that developers update without reviewing. Treat a snapshot update PR the same way you’d treat a schema migration — verify the diff is intentional.
Test Configuration
In .NET, xunit.runner.json configures test runner behavior and you edit *.csproj properties for parallelism. In Vitest, configuration lives in vitest.config.ts (or inline in vite.config.ts):
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()], // resolves path aliases from tsconfig
test: {
globals: true, // removes need to import describe/it/expect in every file
environment: "node", // or "jsdom" for React component tests
coverage: {
provider: "v8", // or "istanbul"
reporter: ["text", "lcov", "html"],
thresholds: {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
},
exclude: [
"node_modules",
"**/*.test.ts",
"**/*.spec.ts",
"**/index.ts", // barrel files
],
},
setupFiles: ["./src/test/setup.ts"], // global test setup — like TestInitialize
},
});
The globals: true option is worth highlighting. Without it, you must import describe, it, expect, and vi at the top of every test file — the same boilerplate every time. With globals: true, they’re available everywhere, matching how Jest traditionally works. Our projects enable this; you’ll see test files without imports and that is intentional.
Coverage Reporting
# Run tests with coverage
pnpm vitest run --coverage
# Output: text summary in terminal + HTML report in ./coverage/
% Coverage report from v8
File | % Stmts | % Branch | % Funcs | % Lines
--------------------|---------|----------|---------|--------
order.service.ts | 94.12 | 83.33 | 100.0 | 94.12
Coverage integrates with SonarCloud via the LCOV reporter (see Article 7.2) and with GitHub Actions to post coverage summaries on PRs. The HTML report at ./coverage/index.html shows line-by-line coverage with branch indicators — equivalent to Visual Studio’s test coverage highlighting.
Our Testing Strategy
Three tiers, each with a different tool:
Unit Tests — Vitest
Tests for individual services, utilities, and pure functions. Dependencies are mocked. These run in milliseconds and should make up the majority of your test suite.
pnpm vitest run # run once
pnpm vitest # watch mode — runs affected tests on file change
pnpm vitest --coverage # with coverage
Integration Tests — Vitest + Supertest
Tests for NestJS API endpoints — the real request pipeline (middleware, guards, pipes, serialization) against a real database running in Docker. Supertest makes HTTP requests to your NestJS application without starting a network listener.
// order.integration.spec.ts
import { Test } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import request from "supertest";
import { AppModule } from "../app.module";
describe("Order API (integration)", () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it("POST /orders creates an order", async () => {
const response = await request(app.getHttpServer())
.post("/orders")
.send({ productId: 1, quantity: 2 })
.set("Authorization", `Bearer ${testToken}`)
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(Number),
productId: 1,
quantity: 2,
});
});
});
This is the JS equivalent of WebApplicationFactory<Program> in ASP.NET integration tests — same concept, slightly different wiring. See Article 5.7 for database setup with Testcontainers.
E2E Tests — Playwright
Tests for complete user flows through the browser. Playwright drives Chromium, Firefox, or WebKit, and its API reads like a well-typed Selenium without the friction.
// tests/e2e/place-order.spec.ts
import { test, expect } from "@playwright/test";
test("user can place an order", async ({ page }) => {
await page.goto("/login");
await page.fill('[name="email"]', "test@example.com");
await page.fill('[name="password"]', "secret");
await page.click('[type="submit"]');
await page.goto("/products/1");
await page.fill('[name="quantity"]', "2");
await page.click("text=Add to Cart");
await page.click("text=Checkout");
await expect(page.locator(".order-confirmation")).toBeVisible();
await expect(page.locator(".order-id")).toContainText(/ORDER-\d+/);
});
pnpm playwright test # run all E2E tests
pnpm playwright test --ui # interactive UI mode (closest to Test Explorer)
pnpm playwright test --debug # step-through debugging with browser visible
Key Differences
| Concern | xUnit + Moq (.NET) | Vitest (TypeScript) |
|---|---|---|
| Test discovery | Attributes ([Fact], [Test]) | File naming (*.test.ts, *.spec.ts) |
| Test file location | Separate test project | Alongside production code |
| Test grouping | Class | describe() block |
| Single test | [Fact] / [Test] | it() or test() |
| Parameterized test | [Theory] + [InlineData] | it.each() |
| Setup (per test) | Constructor | beforeEach() |
| Teardown (per test) | IDisposable.Dispose() | afterEach() |
| Setup (per class) | IClassFixture<T> | beforeAll() |
| Mocking | Moq (separate NuGet package) | vi.fn() / vi.spyOn() (built-in) |
| Module mocking | Not applicable | vi.mock() |
| Call verification | Verify() with Times.* | toHaveBeenCalledTimes() |
| Deep equality | Assert.Equivalent() / BeEquivalentTo() | toEqual() |
| Reference equality | Assert.Same() | toBe() |
| Exception assertion | Assert.ThrowsAsync<T>() | expect(fn()).rejects.toThrow() |
| Snapshot testing | No equivalent | toMatchSnapshot() |
| Coverage tool | coverlet | @vitest/coverage-v8 |
| Config file | xunit.runner.json / .csproj | vitest.config.ts |
Gotchas for .NET Engineers
1. vi.mock() hoists to the top of the file — and it’s surprising
vi.mock() calls are automatically moved to the top of the file by Vitest, before any imports. This means you cannot use variables defined in your test file inside a vi.mock() factory function — they haven’t been initialized yet.
// THIS WILL NOT WORK as expected
const mockSave = vi.fn().mockResolvedValue(42); // defined here
vi.mock("./order.repository", () => ({
OrderRepository: vi.fn().mockImplementation(() => ({
save: mockSave, // ERROR: mockSave is undefined at hoist time
})),
}));
// CORRECT: use vi.fn() inside the factory, configure in beforeEach
vi.mock("./order.repository", () => ({
OrderRepository: vi.fn().mockImplementation(() => ({
save: vi.fn(),
})),
}));
describe("OrderService", () => {
beforeEach(() => {
// Access the mocked module to configure behavior per test
const { OrderRepository } = await import("./order.repository");
vi.mocked(OrderRepository).mockImplementation(() => ({
save: vi.fn().mockResolvedValue(42),
}));
});
});
In practice, you often avoid vi.mock() entirely for services you control by injecting mock objects directly via the constructor — the same pattern as Moq. vi.mock() is most useful for third-party modules and Node.js built-ins you can’t inject.
2. Forgetting await on async expect — the test passes when it should fail
This is the single most common Vitest mistake for engineers coming from any background:
// WRONG: the assertion is a Promise, never awaited — test passes regardless
it("throws on invalid input", () => {
expect(sut.placeOrder({ quantity: 0 })).rejects.toThrow();
// ^ no await — this Promise is ignored
});
// CORRECT
it("throws on invalid input", async () => {
await expect(sut.placeOrder({ quantity: 0 })).rejects.toThrow();
});
In .NET, Assert.ThrowsAsync<T>() forces you to await it because it returns a Task<T>. Vitest’s expect(promise).rejects.toThrow() is just a fluent chain that returns a Promise — and if you forget await, Vitest sees no failed assertion, marks the test green, and moves on. Always await assertions against Promises.
3. Module mocking scope is file-wide, not test-wide
When you call vi.mock(), it affects every test in the file. If you need different behavior for different tests, configure the mock in beforeEach rather than at the module level, and use vi.resetAllMocks() or vi.restoreAllMocks() in afterEach to clean up between tests.
A common pattern for different per-test behavior:
vi.mock("./email.service");
import { EmailService } from "./email.service";
const MockedEmailService = vi.mocked(EmailService);
describe("OrderService", () => {
afterEach(() => {
vi.clearAllMocks(); // clears call history, keeps implementations
});
it("sends email on success", async () => {
MockedEmailService.prototype.sendConfirmation.mockResolvedValue(undefined);
// ...
});
it("still saves order if email fails", async () => {
MockedEmailService.prototype.sendConfirmation.mockRejectedValue(new Error("SMTP down"));
// ...
});
});
4. toBe vs. toEqual is not like == vs. Equals()
Coming from C#, you might assume toBe is value equality and toEqual is reference equality — the opposite is true. toBe uses Object.is(), which is reference equality for objects. toEqual does a deep structural comparison.
const a = { id: 1 };
const b = { id: 1 };
expect(a).toBe(b); // FAILS — different references
expect(a).toEqual(b); // PASSES — same shape
expect(1).toBe(1); // PASSES — primitives compared by value
For asserting on returned objects, almost always use toEqual or toMatchObject (partial match — like BeEquivalentTo with Excluding()).
5. Test isolation requires manual mock cleanup — it is not automatic
In .NET, each test class instantiation gives you fresh mock objects (because [Fact] creates a new class instance). In Vitest, mock state accumulates across tests unless you reset it. The three reset methods have different scopes:
vi.clearAllMocks(); // clears call history (.mock.calls, .mock.results)
// does NOT reset implementations set with mockReturnValue()
vi.resetAllMocks(); // clears call history AND removes implementations
// mocks return undefined after this
vi.restoreAllMocks(); // only applies to vi.spyOn() mocks
// restores original implementation
Convention: put vi.clearAllMocks() in afterEach in your global setup file (vitest.config.ts → setupFiles), and only call vi.resetAllMocks() or vi.restoreAllMocks() when you explicitly need to change implementation mid-suite.
Hands-On Exercise
You have a PricingService that calculates order totals. It depends on a ProductRepository to fetch product prices and a DiscountService to apply promotions.
-
Write the
PricingServiceinsrc/pricing/pricing.service.tswith acalculateTotal(items: OrderItem[]): Promise<number>method that:- Fetches each product’s price via
ProductRepository.getPrice(productId: number) - Applies discounts via
DiscountService.getDiscount(userId: number): Promise<number>(returns a percentage 0-100) - Returns the discounted total
- Throws
ValidationErrorif anyitemsarray is empty
- Fetches each product’s price via
-
Write the tests in
src/pricing/pricing.service.test.tscovering:- Happy path: correct total with discount applied
- Zero discount: total equals sum of prices
- Empty items array: throws
ValidationError - Parameterized: 0%, 10%, 50%, 100% discount all produce correct totals
- Repository or discount service failure: error propagates correctly
-
Run with
pnpm vitest pricing.service(Vitest matches by filename fragment) -
Add coverage reporting and verify the service hits 90%+ line coverage
-
Bonus: add one snapshot test for a fixed input that captures the exact output shape
Quick Reference
| Task | Command / API |
|---|---|
| Run all tests | pnpm vitest run |
| Watch mode | pnpm vitest |
| Run specific file | pnpm vitest order.service |
| Run with coverage | pnpm vitest run --coverage |
| Update snapshots | pnpm vitest run --update-snapshots |
| Run E2E tests | pnpm playwright test |
| Create mock function | vi.fn() |
| Stub return value (sync) | mockFn.mockReturnValue(value) |
| Stub return value (async) | mockFn.mockResolvedValue(value) |
| Stub rejection (async) | mockFn.mockRejectedValue(new Error()) |
| Spy on existing method | vi.spyOn(obj, 'method') |
| Mock a module | vi.mock('./path/to/module') |
| Check call count | expect(mockFn).toHaveBeenCalledTimes(n) |
| Check call arguments | expect(mockFn).toHaveBeenCalledWith(arg1, arg2) |
| Assert never called | expect(mockFn).not.toHaveBeenCalled() |
| Deep equality | expect(actual).toEqual(expected) |
| Partial match | expect(actual).toMatchObject({ id: 1 }) |
| Async throw | await expect(fn()).rejects.toThrow(ErrorClass) |
| Snapshot | expect(value).toMatchSnapshot() |
| Reset call history | vi.clearAllMocks() |
| Reset implementations | vi.resetAllMocks() |
| Restore spies | vi.restoreAllMocks() |
.NET → Vitest concept map
| .NET | Vitest |
|---|---|
[Fact] | it("description", () => {}) |
[Theory] + [InlineData] | it.each([[...], [...]])("desc %s", ...) |
[Skip("reason")] | it.skip("description", ...) |
Assert.Equal(expected, actual) | expect(actual).toBe(expected) |
Assert.Equivalent(expected, actual) | expect(actual).toEqual(expected) |
Assert.Contains(item, list) | expect(list).toContain(item) |
Assert.ThrowsAsync<T>() | await expect(fn()).rejects.toThrow(T) |
Assert.True(condition) | expect(condition).toBe(true) |
Mock<T>().Setup(...) | vi.fn().mockReturnValue(...) |
mock.Verify(...) | expect(mock).toHaveBeenCalledWith(...) |
Times.Once | .toHaveBeenCalledTimes(1) |
Times.Never | .not.toHaveBeenCalled() |
IClassFixture<T> | beforeAll() + afterAll() |
| Constructor | beforeEach() |
IDisposable.Dispose() | afterEach() |
WebApplicationFactory<T> | Test.createTestingModule() (NestJS) |
Further Reading
- Vitest Documentation — the canonical reference; it is terse and accurate
- Playwright Documentation — E2E testing setup and API reference
- NestJS Testing —
TestingModule, Supertest integration, and mocking providers in the DI container - Vitest Mocking Guide — the definitive reference for
vi.mock(),vi.fn(),vi.spyOn(), and their edge cases