SOLID in a test framework
SOLID is applicable to test code, but with an adjustment for the goal: tests must be simple and readable.
- S, Single Responsibility. A Page Object describes the UI, a factory creates data, an API client goes to the API, a test verifies. A Page Object that also creates a user through the DB violates SRP.
- O, Open/Closed. A new login method is added by a new strategy, not by editing a
switch in ten places. - L, Liskov. If
AdminPage extends BasePage, then everywhere BasePage is expected, AdminPage must work correctly. Deep page hierarchies often violate this. - I, Interface Segregation. Don't make one
ApiClient of 200 methods. Better PaymentsApi, UsersApi. - D, Dependency Inversion. A test depends on an abstraction (the
loggedIn fixture), not on a specific way of logging in. In Playwright fixtures implement this naturally.
The main caveat: SOLID in tests is not an end in itself. An interface with one implementation and three layers of abstraction for a 5-line test makes the code worse.
In an interview: The most important for me in tests are SRP and Dependency Inversion: each class does one thing, and tests get dependencies through fixtures. The rest I apply when there is real variability, not for the sake of a template.
DRY vs DAMP and KISS
DRY (Don't Repeat Yourself) says remove duplication. DAMP (Descriptive And Meaningful Phrases) says a test must read on its own, even at the cost of repetition.
In tests you have to balance:
- DRY for infrastructure: locators, steps, fixtures, factories.
- DAMP for the test itself: input data and expectations must be visible in the test body.
Bad (too DRY):
test('limit', async ({ transferPage }) => {
await runScenario(transferPage, SCENARIOS[3]);
});
To understand what is being checked, you have to open two files. Better:
test('blocks transfer above daily limit', async ({ transferPage }) => {
await transferPage.fill('UA002', 50_001);
await transferPage.confirm();
await expect(transferPage.error).toHaveText('Daily limit exceeded');
});
KISS (Keep It Simple) and YAGNI: don't build an abstraction "for the future". Three tests with the same two lines aren't yet a reason for a new layer. Logic in tests (if, loops with conditions) is also a signal: a test must be linear.
Parameterisation through a for loop over an array of cases with test() inside is fine, if each case has a clear name.
In an interview: I apply DRY to infrastructure, and write tests in the DAMP style, so that data and expectations are visible without jumping into other files. I add an abstraction when duplication has become a real problem, not in advance.
Typical antipatterns in frameworks
A list worth knowing and being able to explain:
- A God Page Object and a God Helper: a 2000-line
utils.ts where everything is dumped. - Deep inheritance of pages and tests instead of composition.
- Shared mutable state between tests: module variables, test order as a dependency. In Playwright tests must be isolated, because they run in parallel and may be restarted.
- Hard waits with
waitForTimeout instead of web-first assertions and auto-waiting. - Assertions with no purpose in helpers: a helper silently checks ten things, and it is unclear what failed.
- Wrappers over wrappers: your own
click() that calls locator.click() with no added value. It hides API options and gets in the way of the trace. - Logic in tests:
if (await el.isVisible()) makes a test non-deterministic. - Test data constants that tests mutate.
- Abstraction for growth: Screenplay, interfaces and factories for 20 tests.
How to talk about this in an interview: don't just list them, give an example from experience and what was done. For example: "We had a BasePage with 60 methods, we moved the header and tables into components, and reworked login to storageState. The run time went down, and there were fewer conflicts in PRs."
In an interview: The most frequent antipatterns I have seen are god objects, deep inheritance, shared state between tests and hard timeouts. I fixed them with components, fixtures, isolated data and web-first assertions.