You have finished your backend endpoints. The frontend is still being designed; the UI team has a backlog, or perhaps your service doesn’t need a frontend at all.
That raises a practical question:
How can you verify that your API works before anyone has a screen to click?
You don’t need to wait for a frontend.
An API can be tested directly with HTTP requests, response checks, automated scripts, and a test collection that lives alongside your backend code.
The real goal is not just to confirm that an endpoint responds once. It is to build a repeatable process that can catch broken behavior during development, before a change reaches production.
In this guide, we’ll explore practical API testing methods and build a reusable test workflow with Bruno.
1. Why API Testing Should Start Before the UI
A frontend is useful for testing how users interact with your application. But it shouldn’t be the first place where your backend gets tested.
Consider a typical development workflow:
- A developer creates an API endpoint.
- The frontend team starts integrating it.
- A request fails because the response format is different.
- Both teams investigate the issue.
- The backend is changed and the integration is tested again.
This cycle can take much longer than testing the endpoint directly when it is first created.
Catch backend problems early
An incorrect status code, missing response property, or broken validation rule can often be detected with a simple HTTP request.
You don’t need a complete UI to discover these problems.
Test situations the frontend may never cover
A normal frontend usually follows expected user flows. Automated API tests can deliberately exercise unusual situations:
- An expired authentication token.
- A malformed request body.
- An invalid resource ID.
- An empty result set.
- A request for a page beyond the available data.
- A user attempting to access a resource without permission.
These cases matter because an API serves more than one screen.
Create a record of what works
A manual test in a browser or API client is useful, but it can disappear into your work history.
A saved test collection is different.
It records the request, the expected response, and the steps required to run the test again.
That makes it useful for your team, future development, and continuous integration.
2. Choose the Right API Testing Tool
There isn’t one tool that fits every situation. The best option depends on whether you are investigating one endpoint, sharing requests with a team, or building automated checks.
Option A: curl or HTTPie
For a quick check, the terminal is often enough.
curl http://localhost:3000/ordersYou can immediately see whether the server responds.
This approach is excellent for debugging a single endpoint or sharing a quick request with another developer.
The limitation is repeatability. If requests only exist in shell history, they are not necessarily organized, documented, or easy for the whole team to run.
Option B: A GUI API Client
A graphical API client makes it easier to:
- Create and save requests.
- Configure headers and authentication.
- Inspect response bodies.
- Review status codes.
- Organize endpoints into collections.
- Repeat requests while debugging.
This is especially helpful when developing a new API and trying to understand the actual response structure.
The important question is where the saved requests live. A collection that can be exported and committed to Git is more useful for team workflows than a collection that exists only inside one person’s account.
Option C: Code-Based Testing
Backend developers may prefer writing tests in the same language as their service.
Common examples include:
- Supertest for Node.js applications.
- pytest with requests for Python.
- RestAssured for Java.
Code-based tests are useful when you need application logic, reusable helpers, database setup, or complex assertions.
The trade-off is that the tests require knowledge of the programming language and its testing framework.
Option D: A Collection With a CLI Runner
This approach combines a readable request collection with automated execution.
You save HTTP requests as files, organize them into folders, and run them through a command-line tool.
That gives you:
- Requests that can be reviewed in Git.
- A GUI for developing and debugging requests.
- A CLI for running tests without opening the GUI.
- A workflow that can be connected to CI.
For this tutorial, we’ll use Bruno.
Bruno is an API client that supports file-based collections and command-line execution. Its collections can be stored in a repository so developers can review and update API requests alongside application code.
Official website: https://www.usebruno.com/
3. The Example API
Let’s imagine a small order-management backend.
The API has four endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /auth/login | Authenticate a user |
| GET | /orders | Return a user’s orders |
| POST | /orders | Create a new order |
| GET | /orders/:id | Retrieve one order |
The examples below use these endpoints, but the same testing approach can be adapted to products, users, payments, categories, or any other backend resource.
4. Create Your First Request
Install Bruno and create a collection for your API.
For example, your backend might be running at:
http://localhost:3000Create a request for:
GET /ordersSend the request and inspect three things:
Status code — Did the server return the expected result?
Response body — Is the returned data structured correctly?
Response time — Is the endpoint responding within a reasonable time?
At this stage, you are exploring the API.
The next step is to turn that exploration into a test that can be repeated.
5. Keep the Test Collection With Your Backend
Don’t leave your API tests inside a temporary folder on your computer.
Create a dedicated directory in your service repository.
One possible structure is:
api-tests/
├── opencollection.yml
├── environments/
│ ├── local.yml
│ └── ci.yml
├── auth/
│ └── login.yml
├── orders/
│ ├── list-orders.yml
│ ├── create-order.yml
│ └── get-order.yml
└── errors/
├── unauthenticated.yml
├── forbidden.yml
└── invalid-payload.ymlThe exact layout can vary depending on how you organize your Bruno collection.
The important idea is that the requests are stored as files.
That makes it possible to:
- Review changes in pull requests.
- Share the collection with other developers.
- Run the same tests on different machines.
- Keep API checks close to the backend they verify.
Example request file
A Bruno HTTP request can describe the method, URL, headers, authentication, and other settings.
For example:
info:
name: List Orders
type: http
seq: 1
tags:
- smoke
http:
method: GET
url: "/orders"
params:
- name: limit
value: "25"
type: query
headers:
- name: Accept
value: application/json
auth:
type: bearer
token: ""
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
Lists orders for the authenticated user.
Required variables:
- baseUrl
- tokenThis example describes the request. Your collection environment should provide the base URL, and the authentication token should be supplied through the appropriate variable.
6. Replace Manual Checking With Assertions
A response that looks correct in a GUI is not the same as a tested response.
Suppose your orders endpoint returns:
{
"data": [],
"nextCursor": null
}You might expect:
- HTTP status 200.
datato be an array.nextCursorto be present.
Those expectations should be written as assertions.
Basic assertions
In your Bruno request’s Assert tab, you can configure checks such as:
| Expression | Expected value |
|---|---|
res.status | eq 200 |
res.body.data | isArray |
The exact assertion options depend on the Bruno version and request configuration you use.
Add logic when simple assertions aren’t enough
For example, you may want to check that every order contains the fields your client needs.
A Bruno test script can look like this:
test("returns a page of orders", function () {
const body = res.getBody();
expect(res.getStatus()).to.equal(200);
expect(body.data).to.be.an("array");
expect(body).to.have.property("nextCursor");
});
test("orders contain the required fields", function () {
const body = res.getBody();
body.data.forEach(function (order) {
expect(order).to.have.property("id");
expect(order).to.have.property("status");
expect(order.total).to.be.a("number");
});
});Test the structure, not only one database value
Imagine your database currently contains an order with a total of 4299.
You could assert:
expect(order.total).to.equal(4299);But that test may fail after a database reseed, even when the API is working correctly.
A more reusable check is:
expect(order.total).to.be.a("number");This verifies the type without depending on one specific record.
Use exact values when the test genuinely needs them. For example, a test for a known calculation or a specific business rule should verify the expected result.
7. Test Authentication Without a Login Screen
Protected endpoints create a common problem during backend development.
Your frontend normally handles login, stores the token, and sends it with future requests.
Without a frontend, you can let your API test collection handle that workflow.
Step 1: Send the login request
Create a request for:
POST /auth/loginProvide the test user’s credentials.
The response might look like:
{
"accessToken": "example-token"
}The field name depends on your own API.
Step 2: Save the returned token
In Bruno, you can use a post-response script to save a variable.
For an API response that contains accessToken:
const body = res.getBody();
bru.setVar("token", body.accessToken);Now later requests can reference the token through their authentication configuration.
Step 3: Run the protected endpoint
Your order request uses the saved token as a bearer token.
The sequence becomes:
Login
↓
Save access token
↓
Request orders
↓
Validate responseThis removes the need for a frontend login screen during API testing.
Important: Test failed authentication too
A successful login test isn’t enough.
Also check what happens when:
- The token is missing.
- The token is invalid.
- The token has expired.
- The user doesn’t have permission.
A protected endpoint should return the response your API contract defines for each situation.
8. Test a Complete Create-and-Read Workflow
One of the most useful API tests is a round trip.
Instead of only checking that POST /orders returns a success response, verify that the created order can actually be retrieved.
The workflow looks like this:
Create order
↓
Read returned order ID
↓
GET /orders/:id
↓
Verify the saved resourceFor example, after creating an order:
const body = res.getBody();
bru.setVar("orderId", body.id);The next request can use the saved ID:
GET /orders/{{orderId}}This test verifies more than the HTTP response.
It checks that the create operation returned an ID and that the resource can be retrieved afterward.
The same pattern works for products, users, categories, and other resources.
9. Use Environments Instead of Duplicating Requests
Your local backend and CI environment probably don’t use the same URL.
For example:
Local:
http://localhost:3000
CI:
http://api-test-service:3000You shouldn’t need to edit every request whenever the environment changes.
Instead, define the environment-specific values separately.
Your requests reference a base URL variable, while the environment supplies its value.
Keep credentials out of Git
Never commit real API passwords, access tokens, or production credentials into your test collection.
Use placeholders in committed files and supply sensitive values through your CI secret management system or local environment configuration.
This is particularly important when API tests run automatically on pull requests.
A test suite should be shareable without exposing credentials.
10. Run the Collection From the Terminal
Once your collection is ready, you can execute it through Bruno’s CLI.
For example:
# Run the collection against the local environment
bru run --env local
# Run the orders folder and its subfolders
bru run orders -r --env local
# Run smoke tests in CI
bru run --env ci --tags smoke --exclude-tags skip-ciYou can also configure reporters for test results.
For example:
bru run --env ci \
--reporter-junit results.xml \
--reporter-html report.htmlThe precise CLI flags and supported reporters should be checked against the Bruno version installed in your project.
The benefit is that the collection is no longer tied to your GUI session.
Other developers can run the same tests, and a CI server can execute them without opening an API client.
11. Connect API Tests to CI
The final step is to make the tests part of your development workflow.
For a GitHub Actions setup, the general process is:
- Check out the repository.
- Prepare the API test environment.
- Start or connect to the backend service.
- Run the Bruno collection.
- Save the test results.
- Fail the workflow if required tests fail.
A simplified workflow might look like this:
name: API Tests
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run API tests
uses: usebruno/bruno-cli-action@v1.0.0
with:
working-directory: api-tests
command: run --env ci --tags smokeThis is an illustrative workflow. Your backend must be running and accessible to the test runner before the requests execute.
You may also need to configure database setup, environment variables, test credentials, and report upload steps.
Why CI matters
A developer can run the tests locally and get a passing result.
But a later code change may break an endpoint.
Running the same checks on every pull request helps catch that regression before the change is merged.
12. API Cases You Should Test Before the Frontend Arrives
A basic happy-path test is a starting point, not a complete API test suite.
Here are some cases worth adding.
Authentication
Test:
- Valid credentials.
- Invalid credentials.
- Missing token.
- Expired token.
- Invalid token.
Verify both the status code and the response format.
Authorization
Authentication and authorization are different.
A user may have a valid token but still be unable to access another user’s order.
Test cases should cover the permissions your application actually supports.
Pagination
If your endpoint returns paginated data, test:
- The first page.
- A page containing fewer results.
- The final page.
- An empty result.
- Invalid pagination parameters.
The expected behavior should match your API contract.
Validation errors
Try sending an invalid request body.
For example:
{
"quantity": -1
}If negative quantities are not allowed, your API should return the validation response defined by your application.
Test both the HTTP status and the error body.
Resource not found
Request an ID that does not exist.
Check that the API returns the correct not-found response rather than an unexpected server error.
File uploads
If your API accepts multipart uploads, test the cases that matter to your application:
- Allowed file type.
- Unsupported file type.
- File size limit.
- Missing file.
- Empty file.
Rate limiting
If your API implements rate limiting, verify that the relevant response headers and status codes are returned as documented.
For example, if your API promises a Retry-After header, test that it is present when the applicable limit is reached.
13. Mocking an API Is Not the Same as Testing It
Mock servers are useful, but they solve a different problem.
A frontend team may need an API response before the backend is finished.
A mock can provide that response and allow the frontend to continue development.
However, a mock doesn’t prove that your actual service works.
It may not validate:
- Your database operations.
- Your authentication middleware.
- Your real validation logic.
- Your actual response handling.
- Your deployment configuration.
That’s why both approaches can be useful.
Use mocks when the client needs a predictable API contract.
Use real API tests when you need to verify the backend implementation.
The two workflows support each other, but they should not be confused.
14. A Practical Checklist for Backend Developers
Before handing an API to the frontend team, review the following:
Every important endpoint has a saved request.
Requests are stored in the repository.
Status codes are asserted.
Response bodies are checked for expected structure.
Authentication is tested.
Unauthorized access is tested.
Validation errors are covered.
Create-and-read workflows are tested.
Local and CI environments are configured.
Secrets are not committed.
The collection can run from the terminal.
CI executes the relevant tests on pull requests.
Final Thoughts
A frontend is not a requirement for testing an API.
The backend can be developed and verified independently through HTTP requests, saved collections, assertions, and automated test runs.
Tools such as curl are great for quick checks. GUI clients help you explore and debug. Code-based frameworks are useful for application-level tests. File-based collections provide another way to keep requests readable, shareable, and runnable in CI.
The most valuable improvement is not choosing a particular tool.
It’s moving from:
“I sent the request and it looked fine.”
to:
“I have a saved test that checks the expected behavior, and the same test runs whenever the code changes.”
That’s the difference between manually checking an endpoint and building a dependable API testing workflow.
Build the tests before the UI. Your frontend team will thank you later.



