Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing Philosophy: xUnit/NUnit vs. Jest/Vitest

For .NET engineers who know: xUnit, NUnit, Moq, test projects, [Fact], [Theory], FluentAssertions You’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/NUnitVitest equivalent
Test classdescribe() 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 lifecycleVitest equivalent
ConstructorbeforeEach()
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 toEqual assertions 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

ConcernxUnit + Moq (.NET)Vitest (TypeScript)
Test discoveryAttributes ([Fact], [Test])File naming (*.test.ts, *.spec.ts)
Test file locationSeparate test projectAlongside production code
Test groupingClassdescribe() block
Single test[Fact] / [Test]it() or test()
Parameterized test[Theory] + [InlineData]it.each()
Setup (per test)ConstructorbeforeEach()
Teardown (per test)IDisposable.Dispose()afterEach()
Setup (per class)IClassFixture<T>beforeAll()
MockingMoq (separate NuGet package)vi.fn() / vi.spyOn() (built-in)
Module mockingNot applicablevi.mock()
Call verificationVerify() with Times.*toHaveBeenCalledTimes()
Deep equalityAssert.Equivalent() / BeEquivalentTo()toEqual()
Reference equalityAssert.Same()toBe()
Exception assertionAssert.ThrowsAsync<T>()expect(fn()).rejects.toThrow()
Snapshot testingNo equivalenttoMatchSnapshot()
Coverage toolcoverlet@vitest/coverage-v8
Config filexunit.runner.json / .csprojvitest.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.tssetupFiles), 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.

  1. Write the PricingService in src/pricing/pricing.service.ts with a calculateTotal(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 ValidationError if any items array is empty
  2. Write the tests in src/pricing/pricing.service.test.ts covering:

    • 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
  3. Run with pnpm vitest pricing.service (Vitest matches by filename fragment)

  4. Add coverage reporting and verify the service hits 90%+ line coverage

  5. Bonus: add one snapshot test for a fixed input that captures the exact output shape


Quick Reference

TaskCommand / API
Run all testspnpm vitest run
Watch modepnpm vitest
Run specific filepnpm vitest order.service
Run with coveragepnpm vitest run --coverage
Update snapshotspnpm vitest run --update-snapshots
Run E2E testspnpm playwright test
Create mock functionvi.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 methodvi.spyOn(obj, 'method')
Mock a modulevi.mock('./path/to/module')
Check call countexpect(mockFn).toHaveBeenCalledTimes(n)
Check call argumentsexpect(mockFn).toHaveBeenCalledWith(arg1, arg2)
Assert never calledexpect(mockFn).not.toHaveBeenCalled()
Deep equalityexpect(actual).toEqual(expected)
Partial matchexpect(actual).toMatchObject({ id: 1 })
Async throwawait expect(fn()).rejects.toThrow(ErrorClass)
Snapshotexpect(value).toMatchSnapshot()
Reset call historyvi.clearAllMocks()
Reset implementationsvi.resetAllMocks()
Restore spiesvi.restoreAllMocks()

.NET → Vitest concept map

.NETVitest
[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()
ConstructorbeforeEach()
IDisposable.Dispose()afterEach()
WebApplicationFactory<T>Test.createTestingModule() (NestJS)

Further Reading