main*
📝e2e-testing/04-post-builder.md
📅November 7, 20244 min read

Stop Hand-Writing Giant API Specs

#e2e-testing#api-testing#data-driven#playwright

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.ts owns request methods.
  • PostValidations.ts owns reusable assertions.
  • post.types.ts owns contracts.
  • Post.json owns scenario data.
  • PostTest.spec.ts owns 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.md that records what is actually broken

Previous: The Foundation: TestDefaults & TestDataHelper Next: Negative Cases, Boundary Coverage, and bugs.md ->