Skip to content
Codeloom
REST APIs

REST API Testing: Postman, curl, and Automated Tests

Learn to test REST APIs manually with Postman and curl, then automate with Jest and Supertest. Covers status codes, response validation, and CI integration.

·7 min read · By Codeloom
Beginner 11 min read

What you'll learn

  • Testing APIs with curl from the terminal
  • Using Postman for manual exploration
  • Writing automated API tests with Jest
  • Integrating API tests into CI pipelines

Prerequisites

  • Basic REST API concepts
  • Familiarity with HTTP methods

Why Test Your API

APIs are contracts. Your clients depend on specific endpoints returning specific shapes of data with specific status codes. Without tests, every deployment is a gamble. You might break a response format, return wrong status codes, or introduce regressions that only surface when a customer complains.

API testing catches these problems before they reach production.

Testing with curl

curl is available on every operating system and requires no setup. It is the fastest way to poke at an API.

Basic Requests

# GET request
curl https://jsonplaceholder.typicode.com/posts/1

# Pretty-print JSON (pipe through jq)
curl -s https://jsonplaceholder.typicode.com/posts/1 | jq .

# See response headers
curl -i https://jsonplaceholder.typicode.com/posts/1

# Only see headers (HEAD request)
curl -I https://jsonplaceholder.typicode.com/posts/1

POST, PUT, and DELETE

# POST with JSON body
curl -X POST https://jsonplaceholder.typicode.com/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "New Post", "body": "Content here", "userId": 1}'

# PUT to update
curl -X PUT https://jsonplaceholder.typicode.com/posts/1 \
  -H "Content-Type: application/json" \
  -d '{"id": 1, "title": "Updated Title", "body": "New body", "userId": 1}'

# PATCH for partial update
curl -X PATCH https://jsonplaceholder.typicode.com/posts/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Just the title changed"}'

# DELETE
curl -X DELETE https://jsonplaceholder.typicode.com/posts/1

Authentication

# Bearer token
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
  https://api.example.com/v1/users

# API key
curl -H "X-API-Key: sk_live_abc123" \
  https://api.example.com/v1/data

# Basic auth
curl -u username:password https://api.example.com/v1/data

Useful curl Flags

# Write HTTP status code to stdout
curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health

# Set a timeout (5 seconds)
curl --max-time 5 https://api.example.com/slow-endpoint

# Follow redirects
curl -L https://api.example.com/old-endpoint

# Verbose output (see full request/response)
curl -v https://api.example.com/v1/posts

Testing with Postman

Postman provides a GUI for building, sending, and organizing API requests. It is especially useful for exploring unfamiliar APIs and sharing request collections with your team.

Setting Up a Collection

  1. Create a new collection called “My API.”
  2. Add folders for each resource: Users, Posts, Comments.
  3. Add requests to each folder.
  4. Use environment variables for base URL and tokens.

Environment Variables

Set up variables so you can switch between development and production:

Variable: base_url
Dev value: http://localhost:3000/api
Prod value: https://api.example.com

Variable: auth_token
Value: eyJhbGciOiJIUzI1NiJ9...

Use them in requests as {{base_url}}/users and in headers as Bearer {{auth_token}}.

Writing Postman Tests

Postman has a built-in test runner. Add tests in the “Scripts > Post-response” tab:

// Check status code
pm.test("Status is 200", function () {
  pm.response.to.have.status(200);
});

// Check response time
pm.test("Response time is under 500ms", function () {
  pm.expect(pm.response.responseTime).to.be.below(500);
});

// Validate JSON structure
pm.test("Response has correct shape", function () {
  const json = pm.response.json();
  pm.expect(json).to.have.property("id");
  pm.expect(json).to.have.property("title");
  pm.expect(json).to.have.property("body");
  pm.expect(json.id).to.be.a("number");
});

// Check headers
pm.test("Content-Type is JSON", function () {
  pm.response.to.have.header("Content-Type", "application/json; charset=utf-8");
});

// Save a value for later requests
pm.test("Save user ID", function () {
  const json = pm.response.json();
  pm.environment.set("userId", json.id);
});

Running Collections

Use the Collection Runner to execute all requests in sequence. This is great for testing workflows like “create user, then create post by that user, then fetch the post.”

For CI/CD, export the collection and run it with Newman:

npm install -g newman

newman run my-api-collection.json \
  --environment dev-environment.json \
  --reporters cli,junit \
  --reporter-junit-export results.xml

Automated Testing with Jest and Supertest

Manual testing does not scale. Automated tests run on every commit and catch regressions instantly.

Setup

npm install --save-dev jest supertest

Basic Test Structure

// tests/api/users.test.js
const request = require('supertest');
const app = require('../../src/app');

describe('GET /api/users', () => {
  it('returns a list of users', async () => {
    const res = await request(app)
      .get('/api/users')
      .expect('Content-Type', /json/)
      .expect(200);

    expect(res.body).toBeInstanceOf(Array);
    expect(res.body.length).toBeGreaterThan(0);
    expect(res.body[0]).toHaveProperty('id');
    expect(res.body[0]).toHaveProperty('email');
  });

  it('supports pagination', async () => {
    const res = await request(app)
      .get('/api/users?page=1&limit=5')
      .expect(200);

    expect(res.body.length).toBeLessThanOrEqual(5);
  });
});

Testing CRUD Operations

describe('Users CRUD', () => {
  let createdUserId;

  it('POST /api/users creates a user', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({
        name: 'Jane Doe',
        email: 'jane@example.com',
        role: 'editor',
      })
      .expect(201);

    expect(res.body).toHaveProperty('id');
    expect(res.body.name).toBe('Jane Doe');
    expect(res.body.email).toBe('jane@example.com');
    createdUserId = res.body.id;
  });

  it('GET /api/users/:id returns the created user', async () => {
    const res = await request(app)
      .get(`/api/users/${createdUserId}`)
      .expect(200);

    expect(res.body.id).toBe(createdUserId);
    expect(res.body.name).toBe('Jane Doe');
  });

  it('PUT /api/users/:id updates the user', async () => {
    const res = await request(app)
      .put(`/api/users/${createdUserId}`)
      .send({ name: 'Jane Smith', email: 'jane@example.com', role: 'editor' })
      .expect(200);

    expect(res.body.name).toBe('Jane Smith');
  });

  it('DELETE /api/users/:id removes the user', async () => {
    await request(app)
      .delete(`/api/users/${createdUserId}`)
      .expect(204);

    await request(app)
      .get(`/api/users/${createdUserId}`)
      .expect(404);
  });
});

Testing Error Responses

describe('Error handling', () => {
  it('returns 400 for invalid input', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({ name: '' }) // Missing required fields
      .expect(400);

    expect(res.body).toHaveProperty('error');
    expect(res.body.error).toMatch(/email.*required/i);
  });

  it('returns 404 for non-existent resource', async () => {
    const res = await request(app)
      .get('/api/users/99999')
      .expect(404);

    expect(res.body.error).toBe('User not found');
  });

  it('returns 401 without authentication', async () => {
    await request(app)
      .get('/api/admin/settings')
      .expect(401);
  });

  it('returns 403 with insufficient permissions', async () => {
    const res = await request(app)
      .get('/api/admin/settings')
      .set('Authorization', `Bearer ${editorToken}`)
      .expect(403);

    expect(res.body.error).toMatch(/forbidden/i);
  });
});

Testing with Authentication

describe('Authenticated endpoints', () => {
  let token;

  beforeAll(async () => {
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: 'admin@example.com', password: 'testpass123' });

    token = res.body.accessToken;
  });

  it('returns user profile with valid token', async () => {
    const res = await request(app)
      .get('/api/me/profile')
      .set('Authorization', `Bearer ${token}`)
      .expect(200);

    expect(res.body).toHaveProperty('email', 'admin@example.com');
  });
});

Testing Response Schemas

Use a JSON schema validator to ensure your API always returns the correct shape:

const Ajv = require('ajv');
const ajv = new Ajv();

const userSchema = {
  type: 'object',
  required: ['id', 'name', 'email', 'createdAt'],
  properties: {
    id: { type: 'number' },
    name: { type: 'string', minLength: 1 },
    email: { type: 'string', format: 'email' },
    createdAt: { type: 'string' },
  },
  additionalProperties: false,
};

const validateUser = ajv.compile(userSchema);

it('matches the user schema', async () => {
  const res = await request(app).get('/api/users/1').expect(200);

  const valid = validateUser(res.body);
  if (!valid) {
    console.log(validateUser.errors);
  }
  expect(valid).toBe(true);
});

CI Pipeline Integration

Add API tests to your CI pipeline so they run on every pull request:

# .github/workflows/api-tests.yml
name: API Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run db:migrate
        env:
          DATABASE_URL: postgres://postgres:testpass@localhost:5432/testdb
      - run: npm test -- --coverage
        env:
          DATABASE_URL: postgres://postgres:testpass@localhost:5432/testdb
          JWT_SECRET: test-secret-do-not-use-in-prod

Test Organization Tips

Structure your tests to mirror your routes:

tests/
  api/
    auth.test.js
    users.test.js
    posts.test.js
    comments.test.js
  helpers/
    auth.js          # Login helpers, token generation
    fixtures.js      # Test data factories
    setup.js         # Database setup/teardown

Create helper functions to reduce duplication:

// tests/helpers/auth.js
async function loginAsAdmin(app) {
  const res = await request(app)
    .post('/api/auth/login')
    .send({ email: 'admin@test.com', password: 'password123' });
  return res.body.accessToken;
}

async function loginAsUser(app) {
  const res = await request(app)
    .post('/api/auth/login')
    .send({ email: 'user@test.com', password: 'password123' });
  return res.body.accessToken;
}

module.exports = { loginAsAdmin, loginAsUser };

Wrapping Up

Start with curl for quick manual checks, use Postman for exploring APIs and sharing collections with your team, then build automated tests with Jest and Supertest for your CI pipeline. Test the happy path, error cases, authentication, and response schemas. Automated API tests are some of the highest-value tests you can write because they verify actual HTTP behavior end to end with minimal mocking.