API Testing

By the Syncfusion DevTutorials team · Last updated

Write automated unit and integration tests that check status, headers and body on every change, so a fixed bug never comes back.

API testing is the practice of automatically checking that your code and API behave correctly. Tests send requests, call methods, and verify that the results match what you expect. They run every time the code changes, helping you catch bugs before users do.

Instead of manually checking every feature after every change, automated tests perform those checks for you. This makes development faster, safer, and more reliable.

Watch a Refactor Break the App

Last week, someone cleaned up the Brew & Bytes order code. The API still runs, and on the surface everything looks fine. Press Run.

Program.cs — a refactor nobody tested

Unfortunately, a few important things changed:

  • POST /orders now returns 200 OK.
  • The API contract says it should return 201 Created.
  • The Location header is missing.
  • The mobile app uses the Location header to open the newly created order.

As a result, users see a blank screen even though the API appears to work. Because nobody tested the API after the change, the bug remained unnoticed.

In simple words: automated tests check your API every time the code changes. They catch small mistakes before customers experience them.

TL;DR

  • A test runs code and checks that the result is correct.
  • An assertion is a rule that must be true for the test to pass.
  • Unit tests check a small piece of code, such as a service or helper method.
  • Integration tests send real HTTP requests and verify responses.
  • Test invalid input as well as successful requests.
  • Give each test its own data.
  • Automated tests help prevent old bugs from returning.
  • Common tools include xUnit and WebApplicationFactory in .NET, and Vitest and app.request() in Hono.

A Real-World Example

Imagine a coffee shop before opening.

Every morning, the manager checks whether the coffee machine works, whether the receipts print correctly, whether the card reader is online, and whether the latte is prepared correctly. The manager performs these checks every day, even if everything worked yesterday.

Small changes happen all the time, such as different ingredients, different staff, and equipment updates. Tests serve the same purpose for an API. They verify that important features still work whenever code changes.

A healthy test suite contains many unit tests, fewer integration tests, and a small number of end-to-end tests.

What You Will Learn

In this lesson, you will learn:

  1. Why automated testing is important.
  2. How unit tests verify business logic.
  3. How edge-case tests catch bugs.
  4. How integration tests verify API contracts.
  5. Why tests should use fresh data.
  6. Which testing tools are commonly used in .NET and TypeScript.

Step 1: Your First Unit Tests

A unit test checks a small piece of code in isolation. For example, instead of starting a web server, the test creates an OrderService and calls its methods directly.

OrderServiceTests.cs — a tiny test runner

Each test produces one result: ✓ Passed or ✗ Failed.

The tests verify that correct totals are calculated, that invalid menu items are rejected, that quantities outside the allowed range fail, and that the expected error messages are returned.

For example, 2 Lattes cost ₹440, the GST is ₹22, and the total is ₹462. If every assertion succeeds, the summary is: 4 passed, 0 failed

Real testing frameworks do the same job. They find the tests by themselves, run them, and print detailed reports.

Step 2: A Test That Catches a Bug

A passing test suite is valuable because it catches mistakes immediately. Suppose someone changes the GST calculation. The existing tests still pass. To catch the issue, we add a new test that covers a different value.

OrderServiceTests.cs — a test catches a bug

Example: a Blueberry Muffin costs ₹150, and the GST is 5%. The GST should be ₹7.50, which rounds to ₹8, so the total should be ₹158. Instead, the test reports: expected 158, got 157

The bug occurs because integer division removes the decimal portion. The correct calculation rounds properly before producing the final value.

The important lesson is that the test stays in the project even after the bug is fixed. If the bug reappears later, the test immediately fails.

When you fix a bug, first write a test that shows the bug. After the fix, the test should pass. This kind of test is called a regression test, because it stops old bugs from coming back.

Step 3: Contract Checks Over HTTP

Unit tests verify business logic. They do not verify status codes, headers, request routing, or API contracts. For these checks, use integration tests. Integration tests send real HTTP requests to a running API.

Program.cs — the contract checks

The test suite checks scenarios such as:

Successful Creation

Request: POST /orders. Expected: 201 Created and Location: /orders/103.

Validation Error

Request: quantity = 0. Expected: 400 Bad Request and errors.quantity.

Missing Resource

Request: GET /orders/999. Expected: 404 Not Found.

Idempotent Retry

Two requests use the same Idempotency-Key. Expected: the same order is returned both times, and no duplicate order is created.

Read the Created Resource

Request: GET /orders/104. Expected: the correct order is returned, and the data matches the original response.

If the bug from the beginning returns, the test immediately reports: expected 201, got 200

Make a change, run tests, fix failures, and deploy only when all tests pass.

Step 4: Fresh Data for Every Test

Tests should never depend on previous tests. Each test should start with clean data.

SeedTests.cs — the same tests on three data sets

The same tests run against multiple data sets, called seeds. Each seed contains different menu items, different prices, and different expected results.

Example: the Airport kiosk menu has Latte = ₹310. The GST is ₹15.50, which rounds to ₹16, so the expected total is ₹326.

Running tests against different inputs helps prevent hard-coded solutions from passing incorrectly. Each seed also creates a fresh OrderService, so nothing carries over from a previous test.

Never let tests share state. If one test creates data that another test needs, the tests become unreliable. Each test should be able to run on its own and give the same result every time.

The Real Tools

The in-browser runner cannot run test frameworks, so the code below is read-only. Copy it into a real project to try it.

In .NET, xUnit runs the tests, and WebApplicationFactory<Program> starts your whole API in memory. The test sends a real request without opening a network port.

// OrdersApiTests.cs (needs Microsoft.AspNetCore.Mvc.Testing and xunit)
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;

public class OrdersApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient client;
    public OrdersApiTests(WebApplicationFactory<Program> factory) => client = factory.CreateClient();

    [Fact]
    public async Task Post_order_returns_201_and_location()
    {
        var response = await client.PostAsJsonAsync("/orders", new { item = "Latte", quantity = 2 });
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.StartsWith("/orders/", response.Headers.Location!.ToString());
    }

    [Fact]
    public async Task Quantity_zero_returns_400()
    {
        var response = await client.PostAsJsonAsync("/orders", new { item = "Latte", quantity = 0 });
        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
    }
}

Add public partial class Program { } at the end of Program.cs, so that the test project can see Program. Run the tests with dotnet test.

In Hono, app.request() sends a request straight to your app, with no server. Vitest runs the test file.

// orders.test.ts (needs vitest)
import { describe, expect, it } from "vitest";
import app from "./server";

describe("POST /orders", () => {
  it("returns 201 and a Location", async () => {
    const res = await app.request("/orders", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ item: "Latte", quantity: 2 }),
    });
    expect(res.status).toBe(201);
    expect(res.headers.get("Location")).toMatch(/^\/orders\//);
  });
});

Run it with npx vitest. Both tools print the same kind of summary that you saw above, and both can run in your build pipeline on every push.

Where You See This

  • Pull requests: most teams will not merge a change until every test is green.
  • Build pipelines run dotnet test or npx vitest on every push.
  • Public APIs, such as payment providers, keep thousands of contract tests, so that partners never break.

Common mistakes

SymptomCauseFix
A test passes alone but fails in the full runTests share state, such as one store or one id counterCreate fresh data in each test
Users hit errors that the tests never showedOnly the happy path is testedAdd tests for bad input, missing ids and retries
A test is always green, even when the code breaksThe test has no assertionAssert the exact status, value or message
An old bug comes backThe fix had no testAdd a regression test with the fix
A refactor changes 201 to 200 without anyone noticingTests check the body but not the status or headersAssert the status and Location too

Exercises

What to Remember

  • A test calls your code and asserts the answer. Run tests on every change.
  • Unit tests check one class. Integration tests check status, headers and body over HTTP.
  • Test the bad input and edge cases, not only the happy path.
  • Write a regression test for every bug that you fix.
  • Give every test fresh data. Tests must not depend on each other.

Next: API Gateway. One front door that routes, secures and limits traffic for all your services.