TL;DR
A test case verifies one specific behavior or requirement. A test suite groups related test cases so you can organize, execute, and report on them together. Write focused test cases first, then group them by feature, test type, priority, or environment.
Introduction
As an API grows, its test collection grows with it. Without a clear structure, it becomes difficult to answer practical questions:
- Which tests cover authentication?
- Which tests should run on every commit?
- How do you run only critical checks?
- Which requirement failed when the pipeline broke?
Test cases and test suites solve different parts of this problem. A test case defines what to verify, while a test suite defines how related tests are grouped and executed.
This guide explains the difference and shows how to structure API tests in Jest and Apidog.
What Is a Test Case?
A test case is a single test scenario that verifies one behavior or requirement.
For example:
Given a registered user, when the user submits valid credentials, the login endpoint should return a successful response and an access token.
A useful test case usually includes:
-
Test ID: A unique identifier, such as
TC_AUTH_001 - Description: The behavior being verified
- Preconditions: Required data or environment state
- Test steps: Actions performed during the test
- Expected result: The required outcome
- Actual result: The observed outcome
- Status: Pass, fail, skipped, or blocked
Example test case specification
Test Case ID: TC_AUTH_001
Title: Log in with valid credentials
Preconditions:
- A user account exists
- The account is active
Steps:
1. Send POST /api/auth/login
2. Include a valid email and password
3. Inspect the response status and body
Expected Result:
- Status code is 200
- The response contains a token
- expiresIn is 86400 seconds
Actual Result:
- Recorded during execution
Status:
- Pass or Fail
A test case should be atomic. If one test checks login, profile updates, and logout, split it into separate cases unless you are intentionally implementing an end-to-end workflow.
Test case example in Jest
test('TC_AUTH_001: logs in with valid credentials', async () => {
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'SecurePass123',
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.token).toBeDefined();
expect(data.expiresIn).toBe(86400);
});
This test covers one scenario: successful login. Invalid credentials, locked accounts, expired passwords, and logout should be separate test cases.
Why focused test cases matter
Focused test cases provide:
- Traceability: Map tests to requirements or tickets
- Repeatability: Run the same validation consistently
- Documentation: Show how an endpoint should behave
- Faster debugging: Identify the failing behavior immediately
- Clear reporting: Produce meaningful pass-or-fail results
A test named test user functionality provides little diagnostic value. A test named returns 401 when the password is incorrect tells you exactly what failed.
What Is a Test Suite?
A test suite is a collection of related test cases grouped for organization and execution.
If a test case is one question, a test suite is a section containing related questions.
You can organize suites by:
- Feature: Authentication, users, orders, payments
-
Endpoint:
/api/users,/api/orders - Test type: Smoke, integration, regression, end-to-end
- Priority: Critical, high-priority, optional
- Environment: Staging or production-safe checks
- Execution profile: Fast tests or long-running tests
Example suite structure
Authentication Suite
├── TC_AUTH_001: Login with valid credentials
├── TC_AUTH_002: Login with an invalid password
├── TC_AUTH_003: Login with an unknown email
├── TC_AUTH_004: Refresh an access token
└── TC_AUTH_005: Log out successfully
User Profile Suite
├── TC_PROFILE_001: Get the current profile
├── TC_PROFILE_002: Update profile information
├── TC_PROFILE_003: Upload a profile image
└── TC_PROFILE_004: Delete an account
You can execute one test case during development, one suite before committing, or the complete test collection in CI.
Test suite example in Jest
In Jest, a describe() block groups related test cases:
describe('Authentication API', () => {
test('TC_AUTH_001: logs in with valid credentials', async () => {
// Test implementation
});
test('TC_AUTH_002: rejects an invalid password', async () => {
// Test implementation
});
test('TC_AUTH_003: rejects an unknown email', async () => {
// Test implementation
});
test('TC_AUTH_004: logs out successfully', async () => {
// Test implementation
});
});
describe('User Profile API', () => {
test('TC_PROFILE_001: returns the current profile', async () => {
// Test implementation
});
test('TC_PROFILE_002: updates profile information', async () => {
// Test implementation
});
});
Each test() is a test case. Each describe() is a suite or logical group.
Nested test suites
Nested suites can mirror the structure of a larger API:
describe('API', () => {
describe('Authentication', () => {
describe('Login', () => {
test('accepts valid credentials', async () => {});
test('rejects an invalid password', async () => {});
});
describe('Registration', () => {
test('accepts valid registration data', async () => {});
test('rejects a duplicate email', async () => {});
});
});
describe('User Management', () => {
test('returns the user list', async () => {});
test('updates a user role', async () => {});
});
});
Keep nesting shallow enough that developers can understand a failure without reading a long hierarchy. Two or three suite levels are usually sufficient.
Test Case vs. Test Suite: Key Differences
| Aspect | Test case | Test suite |
|---|---|---|
| Definition | One test scenario | A collection of related test cases |
| Scope | One behavior or requirement | Multiple related behaviors |
| Granularity | Atomic | Composite |
| Execution | Runs one test | Runs a group of tests |
| Purpose | Verify a specific result | Organize and execute tests |
| Result | Pass, fail, or skip | Summary of included test results |
| Example | Login with valid credentials | Authentication tests |
| Jest structure |
test() or it()
|
describe() |
| Maintenance | Update one scenario | Reorganize or configure a group |
A simple analogy
- Test case: A file
- Test suite: A folder containing related files
You can run an individual file or operate on the whole folder. Suites can also contain sub-suites, much like folders can contain subfolders.
Execution differences
Run one Jest test case by name:
npm test -- --testNamePattern="TC_AUTH_001"
Run one test file or suite:
npm test -- authentication.test.js
Run smoke and critical tests that use naming tags:
npm test -- --testNamePattern="smoke|critical"
The exact command depends on your test runner and project configuration, but the execution strategy is the same: use cases for targeted feedback and suites for grouped validation.
How Test Cases and Test Suites Work Together
A practical test hierarchy looks like this:
Project
└── Test suites
└── Test cases
└── Test steps and assertions
A typical workflow is:
- Identify one requirement or behavior.
- Write a focused test case for it.
- Add related cases for errors and edge conditions.
- Group those cases into a suite.
- Run the smallest relevant scope during development.
- Run broader suites in CI or before release.
Reusing one check across multiple suites
A critical check may need to run in both a smoke suite and a feature suite. Avoid copying the implementation. Extract it into a reusable function:
async function expectValidLogin() {
const response = await fetch('https://api.example.com/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'SecurePass123',
}),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.token).toBeDefined();
}
describe('Smoke Tests', () => {
test('TC_AUTH_001: critical login check', expectValidLogin);
});
describe('Authentication Tests', () => {
test('TC_AUTH_001: login with valid credentials', expectValidLogin);
test('TC_AUTH_002: login with invalid credentials', async () => {
// Separate implementation
});
});
This lets the same validation participate in different execution groups without duplicating its logic.
Layer your execution strategy
Use the smallest useful test scope for each stage:
# During development: run one test case
npm test -- --testNamePattern="TC_AUTH_001"
# Before committing: run one feature suite
npm test -- authentication.test.js
# In CI: run smoke and critical checks
npm test -- --testNamePattern="smoke|critical"
# Before release: run the complete test collection
npm test
This approach provides fast local feedback while preserving comprehensive release validation.
Test Cases and Test Suites in API Testing
API tests usually validate several layers of a request-response interaction:
- HTTP method and endpoint
- Request headers and body
- Authentication and authorization
- Response status
- Response headers
- Response schema and values
- Error codes and messages
- Side effects, such as database changes
- Response time, when performance requirements exist
Complete API test case example
test('TC_USER_001: creates a user with valid input', async () => {
// Arrange
const newUser = {
name: 'John Doe',
email: 'john@example.com',
role: 'user',
};
// Act
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token',
},
body: JSON.stringify(newUser),
});
const data = await response.json();
// Assert
expect(response.status).toBe(201);
expect(data.id).toBeDefined();
expect(data.name).toBe(newUser.name);
expect(data.email).toBe(newUser.email);
expect(data.createdAt).toBeDefined();
});
This case verifies user creation. It should not also update, delete, and list users.
For more reliable integration tests, clean up the created record after the assertion:
test('TC_USER_001: creates a user with valid input', async () => {
let createdUserId;
try {
const response = await createUser({
name: 'John Doe',
email: `john-${Date.now()}@example.com`,
role: 'user',
});
expect(response.status).toBe(201);
const data = await response.json();
createdUserId = data.id;
expect(data.name).toBe('John Doe');
expect(data.createdAt).toBeDefined();
} finally {
if (createdUserId) {
await deleteUser(createdUserId);
}
}
});
Organize API suites by endpoint
/api/users Suite
├── GET /api/users
├── POST /api/users
├── GET /api/users/:id
├── PUT /api/users/:id
└── DELETE /api/users/:id
This works well when teams own or maintain specific resources.
Organize API suites by feature
User Management Suite
├── Registration
├── Authentication
├── Profile management
└── Account deletion
This works well when APIs support larger business capabilities spanning multiple endpoints.
Organize API suites by test type
Smoke Suite
├── API health check
├── Authentication check
└── Critical endpoint check
Integration Suite
├── Registration flow
├── Order creation flow
└── Payment processing flow
In practice, many projects combine these approaches. Test files may be organized by feature while tags identify smoke, regression, or critical cases.
Managing Test Cases and Suites in Apidog
Apidog provides a visual workflow for creating API test cases and grouping them into suites.
Create a test case
Open an endpoint, select the Test Cases tab, and click + Add Case.
You can also import an existing debug case:
- Copy: Keep the debug case and create a separate test case from it.
- Move: Convert the debug case into a test case when it is no longer needed for ad hoc debugging.
Configure the case with:
- Group: Positive, negative, boundary, or another purpose
- Case name: A descriptive scenario name
- Request parameters: Path, query, header, and form-data values
- Request body: JSON, XML, raw content, or another supported format
- Pre-processors: Setup logic executed before the request
- Post-processors: Logic executed after the response
- Response validation: Assertions for status, headers, body, or other response components
Use names that describe the expected behavior:
TC_AUTH_001 - Return 200 for valid credentials
TC_AUTH_002 - Return 401 for an invalid password
TC_AUTH_003 - Return 400 when email is missing
Create a test suite
- Open the Tests module.
- Locate Test Suite.
- Click + New, or open the folder menu and select Create Test Suite.
- Enter a suite name and configure basic information such as priority.
- Click Continue to open the suite design page.
Use the suite to group cases by feature or execution purpose:
Authentication
├── Positive Login Cases
├── Negative Login Cases
├── Token Refresh Cases
└── Logout Cases
This visual structure can be useful when teams want:
- Visual test organization
- Built-in request and response validation
- Shared API definitions and test cases
- Test reports
- Automated runs through CI/CD integration
When to Create a Test Case or Test Suite
Create a new test case when
Create a separate case for each independently reportable behavior:
- A new requirement
- A different input scenario
- An edge condition
- A separate error response
- A different role or permission
- A different expected status code
- A different business rule
For example, these should be separate cases:
Login with valid credentials
Login with an invalid password
Login with an unknown email
Login with a locked account
Login without a password
Create a new test suite when
Create a suite when several cases share a meaningful scope:
- They test the same endpoint or feature.
- They belong to the same test type.
- They should run at the same pipeline stage.
- They share setup or teardown behavior.
- They have the same priority.
- They target the same environment.
Do not create a suite only to contain one arbitrary test. The grouping should help developers find, execute, or report on the tests.
Anti-Patterns to Avoid
One test case that validates an entire system
Avoid combining unrelated operations:
// Bad: too many behaviors in one test
test('tests the entire user flow', async () => {
// Register user
// Log in
// Update profile
// Create post
// Delete post
// Log out
// Delete account
});
A failure does not immediately tell you which behavior broke.
Split the behaviors into focused suites and cases:
describe('User Management', () => {
test('TC_USER_001: registers a new user', async () => {});
test('TC_USER_002: logs in with valid credentials', async () => {});
test('TC_USER_003: updates the user profile', async () => {});
});
describe('Content Management', () => {
test('TC_POST_001: creates a post', async () => {});
test('TC_POST_002: deletes a post', async () => {});
});
A full workflow test can still be useful as an end-to-end case, but it should complement focused tests rather than replace them.
Excessively nested suites
Avoid hierarchies that make test output difficult to read:
// Bad: unnecessary nesting
describe('API', () => {
describe('V1', () => {
describe('Users', () => {
describe('Authentication', () => {
describe('Login', () => {
describe('Valid Credentials', () => {
test('with email', async () => {});
});
});
});
});
});
});
Flatten the structure while keeping the scope clear:
describe('API V1: User Authentication', () => {
describe('Login', () => {
test('accepts a valid email and password', async () => {});
test('rejects an invalid password', async () => {});
});
describe('Registration', () => {
test('accepts valid registration data', async () => {});
});
});
Best Practices for Test Cases and Test Suites
1. Use behavior-oriented names
Good test case names describe the input and expected result:
test('returns 200 when credentials are valid', async () => {});
test('returns 401 when the password is incorrect', async () => {});
test('returns 404 when the user does not exist', async () => {});
Avoid vague names:
test('login test', async () => {});
test('test 1', async () => {});
test('check user', async () => {});
Use equally clear suite names:
describe('Authentication API: Login', () => {});
describe('User Profile Management', () => {});
describe('Payment Processing Integration Tests', () => {});
2. Keep test cases independent
A test should not depend on another test running first.
Avoid shared mutable state:
// Bad: the second test depends on the first
let userId;
test('creates a user', async () => {
const user = await createUser();
userId = user.id;
});
test('updates the user', async () => {
await updateUser(userId);
});
Create and clean up the required state inside each test:
test('creates a user', async () => {
const user = await createUser();
try {
expect(user.id).toBeDefined();
} finally {
await deleteUser(user.id);
}
});
test('updates a user', async () => {
const user = await createUser();
try {
const response = await updateUser(user.id, {
name: 'Updated Name',
});
expect(response.status).toBe(200);
} finally {
await deleteUser(user.id);
}
});
Independent tests can run in any order and are easier to parallelize.
3. Mirror your application structure
If your source code is organized by feature, use a similar layout for tests:
src/
├── auth/
│ ├── login.js
│ └── register.js
├── users/
│ ├── profile.js
│ └── settings.js
└── posts/
├── create.js
└── delete.js
tests/
├── auth/
│ ├── login.test.js
│ └── register.test.js
├── users/
│ ├── profile.test.js
│ └── settings.test.js
└── posts/
├── create.test.js
└── delete.test.js
This makes it easier to find the tests associated with a module.
4. Use setup and teardown hooks carefully
Hooks can reduce duplication:
describe('User API', () => {
let authToken;
let testUser;
beforeAll(async () => {
authToken = await getAuthToken();
});
beforeEach(async () => {
testUser = await createTestUser();
});
afterEach(async () => {
await deleteTestUser(testUser.id);
});
afterAll(async () => {
await revokeAuthToken(authToken);
});
test('TC_USER_001: returns the user profile', async () => {
const response = await getUserProfile(testUser.id, authToken);
expect(response.status).toBe(200);
});
test('TC_USER_002: updates the user profile', async () => {
const response = await updateUserProfile(
testUser.id,
{ name: 'Updated Name' },
authToken,
);
expect(response.status).toBe(200);
});
});
Use hooks only for setup shared by the suite. Hidden or overly complex hooks can make test behavior difficult to understand.
5. Tag cases for selective execution
Tags can identify execution groups:
describe('Authentication', () => {
test('[smoke] health endpoint responds', async () => {});
test('[critical] valid credentials return a token', async () => {});
test('[regression] expired tokens are rejected', async () => {});
test('[edge-case] passwords support special characters', async () => {});
});
Run a tagged group:
npm test -- --testNamePattern="smoke"
npm test -- --testNamePattern="critical"
For larger projects, consider runner-specific tagging or separate test projects instead of relying only on names.
6. Use a predictable hierarchy
A useful hierarchy is:
Test type
└── Feature or module
└── Specific operation
└── Test cases
For example:
describe('[Integration] User Management', () => {
describe('Login', () => {
test('accepts valid credentials', async () => {});
test('rejects an invalid password', async () => {});
test('rejects an unknown email', async () => {});
});
});
The hierarchy should improve navigation and reporting, not simply add levels.
Common Mistakes
1. Creating overly broad test cases
Avoid:
test('tests user functionality', async () => {
// Registration, login, profile update, and deletion
});
Prefer:
test('registers a new user', async () => {});
test('logs in a registered user', async () => {});
test('updates a user profile', async () => {});
test('deletes a user account', async () => {});
2. Mixing unrelated cases without suites
Avoid:
test('login case 1', async () => {});
test('profile case 1', async () => {});
test('login case 2', async () => {});
test('order case 1', async () => {});
test('profile case 2', async () => {});
Group related cases:
describe('Login', () => {
test('accepts valid credentials', async () => {});
test('rejects invalid credentials', async () => {});
});
describe('Profile', () => {
test('returns the current profile', async () => {});
test('updates the current profile', async () => {});
});
describe('Orders', () => {
test('creates an order', async () => {});
});
3. Relying on test execution order
Avoid suites where one test creates data for the next:
// Bad: order-dependent tests
describe('User Flow', () => {
test('creates a user', async () => {});
test('updates that user', async () => {});
test('deletes that user', async () => {});
});
Prefer independent test setup:
describe('User API', () => {
test('creates a user', async () => {
const user = await createUser();
await deleteUser(user.id);
});
test('updates a user', async () => {
const user = await createUser();
try {
await updateUser(user.id);
} finally {
await deleteUser(user.id);
}
});
test('deletes a user', async () => {
const user = await createUser();
const response = await deleteUser(user.id);
expect(response.status).toBe(204);
});
});
If you need to validate a sequential business flow, implement it as one clearly labeled end-to-end case.
4. Using names that do not explain failures
Avoid:
describe('Suite 1', () => {
test('test 1', async () => {});
test('test 2', async () => {});
});
Prefer:
describe('Authentication API', () => {
test('returns a JWT after successful login', async () => {});
test('returns 401 for invalid credentials', async () => {});
});
Real-World Example: E-Commerce API
The following structure separates fast health checks from feature-level integration tests.
describe('[Smoke] Critical API Endpoints', () => {
test('TC_SMOKE_001: health endpoint returns 200', async () => {
const response = await fetch('https://api.shop.com/health');
expect(response.status).toBe(200);
});
test('TC_SMOKE_002: database status reports a connection', async () => {
const response = await fetch('https://api.shop.com/db-status');
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveProperty('connected', true);
});
});
describe('[Integration] Authentication', () => {
describe('Registration', () => {
test('TC_AUTH_001: registers a valid email and password', async () => {
// Test implementation
});
test('TC_AUTH_002: rejects a duplicate email', async () => {
// Test implementation
});
test('TC_AUTH_003: rejects a weak password', async () => {
// Test implementation
});
});
describe('Login', () => {
test('TC_AUTH_004: accepts valid credentials', async () => {
// Test implementation
});
test('TC_AUTH_005: rejects an invalid password', async () => {
// Test implementation
});
});
});
describe('[Integration] Product Management', () => {
test('TC_PROD_001: returns the product list', async () => {
// Test implementation
});
test('TC_PROD_002: returns a product by ID', async () => {
// Test implementation
});
test('TC_PROD_003: searches products by name', async () => {
// Test implementation
});
test('TC_PROD_004: filters products by category', async () => {
// Test implementation
});
});
describe('[Integration] Order Processing', () => {
test('TC_ORDER_001: creates an order with valid items', async () => {
// Test implementation
});
test('TC_ORDER_002: calculates the correct total', async () => {
// Test implementation
});
test('TC_ORDER_003: applies a valid discount code', async () => {
// Test implementation
});
test('TC_ORDER_004: processes a payment', async () => {
// Test implementation
});
});
A corresponding visual structure in Apidog could look like this:
📁 E-commerce API Tests
📁 Smoke Tests
✓ API Health Check
✓ Database Status
📁 Authentication
📁 Registration
✓ Valid Registration
✓ Duplicate Email
✓ Weak Password
📁 Login
✓ Valid Login
✓ Invalid Password
📁 Products
✓ List Products
✓ Get Product Details
✓ Search Products
📁 Orders
✓ Create Order
✓ Calculate Total
✓ Apply Discount
Each test case can include:
- Request URL and method
- Headers and body
- Pre-request setup
- Response assertions
- Post-response cleanup
You can then run one case, one suite, or a broader collection based on the required feedback scope.
Implementation Checklist
Use this checklist when organizing an existing API test collection:
- Give every test case a descriptive name.
- Make each case verify one independently reportable behavior.
- Separate success, validation, authorization, and error scenarios.
- Remove dependencies between test cases.
- Group related cases by feature or endpoint.
- Identify smoke and critical cases.
- Add setup and cleanup for generated test data.
- Keep suite nesting shallow.
- Run focused cases locally and broader suites in CI.
- Review suite boundaries as the API evolves.
Conclusion
Test cases and test suites are complementary:
- A test case verifies one scenario.
- A test suite groups related test cases.
- Cases should be focused, independent, and clearly named.
- Suites should reflect meaningful execution or ownership boundaries.
- Tags and suite structure should make targeted execution easy.
- Setup and cleanup should keep API tests repeatable.
Start with one test case per important API behavior. As the collection grows, group cases by feature, endpoint, priority, or test type. Whether you use Jest, another test runner, or a visual tool such as Apidog, the same principle applies: keep cases atomic and suites purposeful.
FAQ
What is the main difference between a test case and a test suite?
A test case verifies one specific behavior or requirement. A test suite groups multiple related test cases for organization, execution, and reporting.
Can a test case belong to multiple test suites?
Yes, depending on the tool and test architecture. For example, a login check may be part of both a smoke suite and an authentication suite. Reuse the underlying test logic rather than copying the implementation.
How many test cases should a suite contain?
There is no universal limit. Create a suite when the grouping improves navigation, execution, ownership, or reporting. If a suite becomes difficult to understand, split it into smaller feature- or operation-specific suites.
Should I create test cases or suites first?
Start with the behaviors you need to verify and write focused test cases. Group related cases into suites once a meaningful structure becomes clear.
What is the difference between a test scenario and a test suite?
A test scenario is a high-level description of something to validate, such as “user login.” A test suite is an executable collection of cases that may cover valid login, invalid passwords, locked accounts, and missing fields.
How should I organize suites for a large API?
Start with top-level features or modules, then group by operation or test type. For example:
User Management
└── Authentication
├── Login cases
├── Registration cases
└── Token refresh cases
Keep nesting limited to the levels that improve navigation.
Can test suites contain other test suites?
Yes. Nested suites can represent modules and sub-features. Avoid excessive nesting because it makes test output and maintenance harder to follow.
Which tools support test cases and test suites?
Common options include Jest and Mocha for JavaScript, Pytest for Python, JUnit for Java, and API testing tools such as Postman and Apidog. Choose based on your language, automation requirements, and preferred code-based or visual workflow.







Top comments (0)