Architecture of an API testing framework β
Notes on how an API automation framework is structured in Java + JUnit 5 + REST Assured, based on the framework I work with daily. The principles apply just the same with other stacks (pytest + requests, Playwright API testingβ¦).
The goal: the tester only writes tests β
A testing framework is an internal product. Its mission is to provide a working skeleton so that whoever writes tests can focus on defining services and cases, not on solving authentication, reporting, or CI execution every single time. Design principles:
- Scalable: a single framework serving several teams, without duplicating effort.
- Maintainable: built for the long term; an API change shouldn't force you to rewrite a thousand tests.
- Integrated into the ecosystem (DevOps culture): pipelines, test case manager, tracker, and repository talk to each other through the framework.
The three layers β
ββββββββββββββββββββββββββββββββββββββββββββββββ
β Test case layer β β WHAT is validated
β (scenarios that validate features) β
ββββββββββββββββββββββββββββββββββββββββββββββββ€
β Service object layer β β HOW you talk to the API
β (one object per service/endpoint) β
ββββββββββββββββββββββββββββββββββββββββββββββββ€
β Infrastructure layer β β WHERE and WITH WHAT
β (libraries, CI/CD, reporting, logs) β
ββββββββββββββββββββββββββββββββββββββββββββββββThe key piece is the service object: the equivalent of a UI Page Object, but for APIs. Each service encapsulates all the interactions with its endpoint. If the endpoint changes, its service object changes β the tests never notice.
public class ServiceOrdersService extends ServiceBase {
public ValidatableResponse createOrder(Object body) { ... }
public ValidatableResponse getOrder(String orderId) { ... }
public ValidatableResponse cancelOrder(String orderId) { ... }
}All services inherit from a ServiceBase that concentrates the common bits (authentication, request building, HTTP methods). Inheriting from a common base isn't just about saving code: it forces everyone to authenticate and build requests the same way.
Project organization β
src/test/java/
βββ cases/ # tests, organized by API version β domain β feature
β βββ v2/catalog/products/CreateProductTest.java
βββ suites/ # JUnit suites per domain
βββ services/ # service objects (CatalogService, ServiceOrdersServiceβ¦)
βββ model/ # request/response POJOs
βββ helpers/ # utilities: random data, user creationβ¦
src/test/resources/
βββ env/ # one config directory per environment
β βββ local/config.properties
βββ schemas/ # JSON Schemas per version β service (+ reusable generics)Two details I find particularly well thought out:
Multi-environment via properties: each environment has its own
config.properties(base URL, admin credentials, flags). The test points at one via an environment variable, without touching code:bashENV_PATH=local TEST_SUITE=V2Catalog ./gradlew :test --tests $TEST_SUITESuites per product domain: day to day you don't run individual tests but suites (
@Suite+@SelectPackages("cases.v2.catalog")), which is exactly how the pipelines are organized. Suite = package = domain: the same unit in code, execution, and CI.
Test class hierarchy β
Three levels of inheritance that balance reuse and performance:
TestBaseβ global: lifecycle, logging, reporting (with a JUnit 5TestWatcher).<Domain>TestBaseβ per domain: declares the shared users/data asprotected static final, so they're created only once for all the classes in the domain.- The concrete test class β just cases.
Lifecycle with JUnit 5 β
Order: @BeforeAll β (@BeforeEach β test β @AfterEach) Γ N β @AfterAll.
@BeforeAll/@AfterAllmust bestatic(JUnit creates a new instance per test).@BeforeAll: create the test's shared users/data.@BeforeEach: reset state if the test needs it clean.- Hooks exist to eliminate duplication, not to hide logic: if a hook does something that affects the test's outcome, it should probably live in the test.
Tests must be atomic
The rule that comes before everything else: every test must be able to run on its own and in any order. Suites group, they don't chain.
A custom assertion facade β
All assertions go through a custom utility class (e.g. TestCaseReport.assertResponseCode(...)) instead of calling JUnit/AssertJ directly. Each assert, besides validating, writes to the log and to the HTML report. Three advantages:
- Homogeneous messages across the whole suite.
- Every validation is traced in the report (not just the final failure).
- If you switch assertion libraries tomorrow, you touch one class, not a thousand tests.