Stop Hand-Writing Giant API Specs
category: "E2E Testing"
Stop Hand-Writing Giant API Specs
The Real Problem
When people say an API suite is "hard to maintain," they usually mean one file is doing four jobs badly:
- constructing requests
- inventing scenarios inline
- asserting every field by hand
- documenting bugs in comments or not at all
That is how you end up with a 500-line spec nobody wants to touch.
What We Want Instead
For a domain like posts, split responsibility cleanly.
PostUtils.tsowns request methods.PostValidations.tsowns reusable assertions.post.types.tsowns contracts.Post.jsonowns scenario data.PostTest.spec.tsowns orchestration.
The spec becomes small on purpose.
The Files
PostUtils.ts
enum PostEndpoint {
CREATE = '/api/articles',
BY_SLUG = '/api/articles/{slug}',
}
export class PostUtils extends PlaywrightUtils {
@Step('Create post')
async createPost(request: CreatePostRequest): Promise<CreatePostResponse> {
const response = await this.processRequest(
HttpMethod.POST,
this.constructUrl(testConfig.apiUrl, PostEndpoint.CREATE),
this.getHeaderMap(),
JSON.stringify({ article: request })
);
const data = await response.json();
return { ...data, apiResponse: response };
}
@Step('Get post by slug')
async getPostBySlug(slug: string): Promise<PostResponse> {
const response = await this.processRequest(
HttpMethod.GET,
this.constructUrl(
testConfig.apiUrl,
PostEndpoint.BY_SLUG.replace('{slug}', slug)
),
this.getHeaderMap()
);
const data = await response.json();
return { ...data, apiResponse: response };
}
}PostValidations.ts
export class PostValidations {
postFields: (keyof Post)[] = ['title', 'description', 'body'];
@Step('Validate post fields')
validatePost(
softAssert: SoftAssertExt,
actual: Post,
expected: Partial<Post>
): void {
const triples = this.postFields.map((field) => [
field,
actual[field],
expected[field],
]) as [string, unknown, unknown][];
BaseSetupAPI.validateActualVsExpected(softAssert, 'Post: ', triples);
}
}post.types.ts
export interface CreatePostRequest {
title: string;
description: string;
body: string;
tagList?: string[];
}
export interface Post {
slug: string;
title: string;
description: string;
body?: string;
tagList: string[];
}
export interface CreatePostResponse {
article: Post;
apiResponse?: APIResponse;
}Put Scenarios in JSON
Post.json
{
"tc_createPostTest": [
{
"useCaseId": "tc_createPostTest.01",
"description": "Create post with full payload",
"request": {
"title": "Hello World",
"description": "Short summary",
"body": "Long-form content",
"tagList": ["testing", "playwright"]
},
"expected": {
"status": 201,
"title": "Hello World",
"description": "Short summary"
}
},
{
"useCaseId": "tc_createPostTest.02",
"description": "Create post without tags",
"request": {
"title": "No Tags",
"description": "Still valid",
"body": "Still content"
},
"expected": {
"status": 201,
"title": "No Tags"
}
}
]
}That is the entire point. New coverage usually means a new JSON row, not a new wall of duplicated code.
The Spec Should Mostly Loop
PostTest.spec.ts
const JSON_FILE = path.resolve(__dirname, 'Post.json');
const baseSetup = new BaseSetupAPI();
test.describe('Post API Tests', () => {
let postUtils: PostUtils;
let postValidations: PostValidations;
test.beforeAll(async () => {
postUtils = new PostUtils();
postValidations = new PostValidations();
});
test.beforeEach(async () => {
await label(LabelName.EPIC, 'API');
await label(LabelName.FEATURE, 'Posts');
await label(LabelName.STORY, 'Create Post');
});
const cases = baseSetup.getTestCases(JSON_FILE, 'tc_createPostTest');
for (const tc of cases) {
test(`[${tc.useCaseId}] ${tc.description}`, async () => {
const softAssert = new SoftAssertExt();
const response = await postUtils.createPost(tc.request);
BaseSetupAPI.validateActualVsExpected(softAssert, 'Post: ', [
['response status', response.apiResponse?.status(), tc.expected.status],
]);
postValidations.validatePost(softAssert, response.article, tc.expected);
softAssert.assertAll();
});
}
});The value here is not that the loop is clever. It is that the spec stays readable even when the scenario count grows.
Why This Is Easier to Debug
When a failure happens, you immediately know:
- which use case failed
- which field failed
- whether the issue was transport, contract, or validation
You are not hunting through a monster spec file trying to understand what branch of setup produced the payload.
What This Article Is Not About
This is not the builder article.
At this stage we are testing the post endpoints directly. No fluent seeding layer yet. No dependency helpers yet. Just direct API behavior, covered systematically.
That is important because every higher-level convenience we add later needs this layer underneath it.
What Comes Next
Once the positive path is clean, the next step is to separate and sharpen the failure path:
- invalid payloads
- boundary overflows
- auth failures
- conflicting writes
- a
bugs.mdthat records what is actually broken
Previous: The Foundation: TestDefaults & TestDataHelper Next: Negative Cases, Boundary Coverage, and bugs.md ->