Test Architecture Design: 6 Decisions to Make Early

Starting test automation in a project is easy. Base architecture, a few simple tests, and it works. The trouble comes later, when the suite grows and the early shortcuts get expensive to undo.

So it pays to decide a few things before you start. Below are six problems I keep running into, and what solves each of them.

Page Object Model

The Page Object Model (POM) is a design pattern that removes code duplication and makes tests easier to maintain. You write one class per web page. Each class holds the locators (selectors) of that page’s elements and the methods that act on them.

It fits the most common things you do in test automation: find an element, fill a value, click a button. In a small script you can keep selectors inside the tests. In a growing suite you get duplicates, and then one changed selector means hunting down every place you used it. Pair it with selectors that survive UI changes. Playwright has its own page object model guide.

With POM, every test uses the same class. A selector changes, you fix it in one place.

There is another approach, App Actions. I’ve never used it, so I can’t recommend it, but this article explains it well: Stop using Page Objects and start using App Actions.

Logging in test automation

Your tests will most likely run on a remote machine. When one fails, you need to see quickly what happened. A stack trace and the last assertion message are rarely enough to reproduce it.

A good logging strategy shows step by step what the test did. On one project we used three steps:

  • begin,
  • info,
  • end.

To avoid writing this by hand everywhere, we used decorators (TypeScript only). TypeScript decorators apply to class methods, not plain functions, so the logged method lives in a page object class. Example based on Puppeteer:

import type { Page } from 'puppeteer';

// Wraps a method and logs begin, result and end around each call.
function logger(
  target: unknown,
  name: string,
  descriptor: PropertyDescriptor
) {
  const original = descriptor.value;

  descriptor.value = async function (...args: unknown[]) {
    console.log(`[BEGIN] - ${name} from: ${args.join(', ')}`);
    const result = await original.apply(this, args);
    console.log(`[INFO] - ${name} result: ${result}`);
    console.log(`[END] - ${name} from: ${args.join(', ')}`);
    return result;
  };
}

class ProfilePage {
  constructor(private page: Page) {}

  @logger
  async getText(selector: string) {
    return this.page.$eval(selector, (el) => el.textContent);
  }
}

This uses the legacy decorator syntax, so it needs experimentalDecorators enabled in tsconfig.json. If you use Playwright, test.step() gives you named steps in the report without custom decorators.

Result:

[BEGIN] - getText from: #email
[INFO] - getText result: [email protected]
[END] - getText from: #email

We wanted to know when a method starts, what it returns and when it ends. The method name and its parameters identify each line. The logs came out well structured, and we could see exactly what happened during a run. You can also feed them into your reports (see below).

Write custom assertion messages too. Compare:

Expect: 5, Received: 8

Without knowing what the test checks, this tells you nothing. You have to open the test and read it. Now this:

We expect 5 elements on the listing, got 8.

You see the problem straight from the message.

Detailed messages also help in two other places:

  • timeout exceptions, for example an internet connection problem,
  • architecture exceptions, for example a database or auth connection problem.

Test data

Most of your tests fill in some data. You have to get it or generate it, and it takes more time than people expect.

I split test data into two types:

  • static data, stored in a database, a repository or another fixed place,
  • dynamic data, generated with tools like faker.

With a few classes you can build a layer that handles both for you: connecting to a database, or generating values on the fly (for example with @faker-js/faker).

Parallel test execution

Parallelization launches your tests at the same time instead of one after another, spreading them across the resources you have. The goal is shorter execution time.

An example with simple numbers. You have 50 tests and each takes about 1 minute, so the run takes 50 minutes. The tests are isolated (if not, fix that first). Run them in 5 parallel threads and the total drops to about 10 minutes.

If you don’t want to build the splitting yourself, Knapsack Pro distributes tests across parallel runners for you. How to size the run and wire it into a pipeline is in parallel tests in CI/CD.

Deployment pipeline

A deployment pipeline takes code from version control and makes it available to users automatically. It builds, tests and deploys. Years ago deployments were manual and happened a few times a week. Now they happen a few times a day, or an hour.

Think about the same 50 tests that take 50 minutes without parallelization. To check fresh code, a developer builds the app locally and runs the suite. A test finds an issue, they fix it and run it again. After a few rounds they stop running the tests. That’s the outcome you want to avoid.

A pipeline runs the tests for them. Which suites run on every deploy is the subject of smoke, sanity and regression testing. Combine it with parallel execution and you get a fast check that guards production. Better still, make failed tests block the deployment, so broken code never reaches real users.

Test reports

A report documents what happened in a test run. It serves two audiences: a summary for management and stakeholders, and details for developers who need to know why something failed.

Say a run fails on a remote machine. Searching plain logs for the one failing test is painful. A good report answers that for you.

Reports differ between teams, but I want these in every one:

  • machine or environment name,
  • total number of tests run,
  • duration,
  • list of all test cases, with the logs (steps from the logging system) in a toggle,
  • result of every test case.

Watching a pipeline dashboard or scrolling through logs is slow. A readable report plus a Slack notification speeds up that triage a lot.

Summary

These six topics are worth settling in any test automation project that is meant to last. Page objects keep selectors in one place. Logging and detailed assertion messages cut troubleshooting time. Parallel runs and a pipeline keep the feedback loop short, and reports show everyone what failed.

Two more things I’d consider:

  • base architecture, a pattern for reusing base methods and creating browsers,
  • video recordings of the whole test session.

A structural decision I covered separately: keep tests in the project repository or a separate one.