Model-Based Testing with Playwright

Model-Based Testing structures tests around two key abstractions: a model that holds the data used in the test, and a page object that represents the UI of the system under test. The combination of a predictable data structure and encapsulated actions enables clean, reusable tests, even as a UI evolves over time.

The model is a plain interface, representing what data might exist on a given page. Every field is optional, as the page only validates what the test actually provides.

Adding a new assertion only requires a new field here and updating the page object - every existing test continues to pass unchanged.

All page interactions live inside the page object, following the Arrange, Act, Assert pattern. Locators, wait strategies, and assertions are encapsulated here.

Methods like setPageFromModel() and validateAgainstModel() are common ways to translate model data into Playwright actions.

The test spec fits everything together. There are no selectors or browser APIs; the test itself simply describes the expected behavior by passing a model for the page object to validate (which can be instantiated outside the test and passed in if required).

Try clicking Run test below to see it in action!
export interface PortfolioPageModel {
  title?: RegExp | string;
  heroText?: RegExp | string;
  projects?: Array<{
    title: string;
    company: string;
  }>;
  skillsVisible?: boolean;
}
import { type Page, expect } from '@playwright/test';
import type { PortfolioPageModel } from '../models/portfolioPageModel';

export class PortfolioPage {
  private readonly heading =
    this.page.getByRole('heading', { level: 1 });
  private readonly projectTiles =
    this.page.locator('.project-tile');
  private readonly skillsSection =
    this.page.getByRole('heading', { name: 'Skills' });

  constructor(private readonly page: Page) {}

  async goto() {
    await this.page.goto('/');
  }

  async validateAgainstModel(model: PortfolioPageModel) {
    if (model.title)
      await expect(this.page).toHaveTitle(model.title);

    if (model.heroText)
      await expect(this.heading).toContainText(model.heroText);

    if (model.projects) {
      for (const project of model.projects) {
        await expect(
          this.projectTiles.filter({ hasText: project.title })
        ).toBeVisible();
      }
    }

    if (model.skillsVisible)
      await expect(this.skillsSection).toBeVisible();
  }
}
import { test } from '@playwright/test';
import { PortfolioPage } from './pages/portfolioPage';

test('portfolio homepage renders correctly', async ({ page }) => {
  const portfolio = new PortfolioPage(page);
  await portfolio.goto();

  await portfolio.validateAgainstModel({
    title: /Matthew Grant/,
    heroText: /quality at every layer/i,
    projects: [
      { title: 'Data & Reporting Platform', company: 'MRI Software'           },
      { title: 'Data Migration Project',    company: 'Proptech Group Limited'  },
      { title: 'CRM & Contracts Platform',  company: 'Harcourts International' },
    ],
    skillsVisible: true,
  });
});
public class PortfolioPageModel
{
    public string? Title { get; set; }
    public string? HeroText { get; set; }
    public List<ProjectItem>? Projects { get; set; }
    public bool? SkillsVisible { get; set; }
}

public record ProjectItem(string Title, string Company);
using Microsoft.Playwright;
using static Microsoft.Playwright.Assertions;

public class PortfolioPage(IPage page) : PageBase(page)
{
    private ILocator Heading  => Page.GetByRole(AriaRole.Heading, new() { Level = 1 });
    private ILocator Projects => Page.Locator(".project-tile");
    private ILocator Skills   => Page.GetByRole(AriaRole.Heading, new() { Name = "Skills" });

    public Task Goto() => Page.GotoAsync("/");

    public override async Task ValidatePageAgainstModel(PortfolioPageModel model)
    {
        if (model.Title is not null)
            await Expect(Page).ToHaveTitleAsync(new Regex(model.Title));

        if (model.HeroText is not null)
            await Expect(Heading).ToContainTextAsync(model.HeroText,
                new() { IgnoreCase = true });

        if (model.Projects is not null)
            foreach (var project in model.Projects)
                await Expect(
                    Projects.Filter(new() { HasText = project.Title })
                ).ToBeVisibleAsync();

        if (model.SkillsVisible is true)
            await Expect(Skills).ToBeVisibleAsync();
    }
}
using NUnit.Framework;

[TestFixture]
public class PortfolioTests : TestBase
{
    private PortfolioPage _portfolio;

    [SetUp]
    public async Task SetUp()
    {
        _portfolio = Pages.Portfolio;
        await Task.CompletedTask;
    }

    [Test]
    [Description("Portfolio homepage renders correctly.")]
    public async Task TestHomepageRendersCorrectly()
    {
        var model = new PortfolioPageModel
        {
            Title         = "Matthew Grant",
            HeroText      = "quality at every layer",
            Projects      =
            [
                new() { Title = "Data & Reporting Platform", Company = "MRI Software"           },
                new() { Title = "Data Migration Project",    Company = "Proptech Group Limited"  },
                new() { Title = "CRM & Contracts Platform",  Company = "Harcourts International" },
            ],
            SkillsVisible = true,
        };

        await _portfolio.Goto();
        await _portfolio.ValidatePageAgainstModel(model);
    }
}
Terminal
$ Waiting to run…