main*
📝e2e-testing/11-as-seeding.md
📅January 24, 20254 min read

Inline Data Seeding: The .as() Method

#e2e-testing#seeding#data-preparation#test-data

category: "E2E Testing"

Inline Data Seeding: The .as() Method

The Goal

Seed data right where you need it instead of hiding it in beforeEach:

await page.goto('/dashboard')
  .as(testData.user().withDefaults().create())
  .perform(ClickOn('new-post'))
  .verify(Shows('Welcome'));

This is what keeps setup local to the test and out of shared hooks.

Implementing the .as() Extension

We extend Playwright's Page or create a wrapper:

// src/pages/extensions/FluentPage.ts
import { Page, Locator } from '@playwright/test';
import { TestDataFactory } from '../../factories/TestDataFactory';
 
export class FluentPage {
  private page: Page;
  private seededData: any = null;
  
  constructor(page: Page) {
    this.page = page;
  }
  
  // ============ Navigation ============
  
  goto(url: string): this {
    this.page.goto(url);
    return this;
  }
  
  async click(selector: string): Promise<this> {
    await this.page.click(selector);
    return this;
  }
  
  async fill(selector: string, value: string): Promise<this> {
    await this.page.fill(selector, value);
    return this;
  }
  
  // ============ The .as() Method ============
  
  async as<T>(factoryMethod: () => Promise<T>): Promise<this> {
    // Create the test data BEFORE continuing
    this.seededData = await factoryMethod();
    return this;
  }
  
  // Access seeded data in tests
  getData<T>(): T {
    return this.seededData as T;
  }
  
  // ============ Assertion ============
  
  async verify(matcher: (page: Page) => Promise<void>): Promise<void> {
    await matcher(this.page);
  }
  
  // ============ Wrap existing page methods ============
  
  get locator() {
    return this.page.locator.bind(this.page);
  }
  
  get waitForSelector() {
    return this.page.waitForSelector.bind(this.page);
  }
  
  // Delegate other methods
  async click(selector: string, options?: any): Promise<this> {
    await this.page.click(selector, options);
    return this;
  }
}

Creating a Test Fixture

// src/fixtures/index.ts
import { test as base, Page } from '@playwright/test';
import { FluentPage } from '../pages/extensions/FluentPage';
 
export const test = base.extend<{
  page: Page & { asPage: () => FluentPage };
}>({
  page: async ({ page }, use) => {
    // Add asPage() to every page
    const fluentPage = new FluentPage(page);
    
    // Extend page with fluent methods
    const extendedPage = Object.assign(page, {
      asPage: () => fluentPage,
      as<T>(factoryMethod: () => Promise<T>): Promise<FluentPage> {
        return fluentPage.as(factoryMethod);
      },
    });
    
    await use(extendedPage as any);
  },
});
 
export { expect } from '@playwright/test';

Using .as() in Tests

import { test, expect } from '../fixtures';
 
test('user sees their profile after login', async ({ page }) => {
  await page.goto('/')
    .as(async () => {
      // Create user and get token
      const { user } = await testData.user()
        .withUsername('testuser')
        .withDefaults()
        .create();
      return user;
    })
    .click('text=Sign in')
    .fill('input[type=email]', 'testuser@test.com')
    .fill('input[type=password]', 'password123')
    .click('button[type=submit]');
    
  // Verify
  await expect(page.locator('text=testuser')).toBeVisible();
});
 
// Even cleaner - seed BEFORE navigation
test('dashboard with seeded user', async ({ page, testData }) => {
  // Create data first
  const { user, articles } = await testData.post()
    .withAllDeps()
    .create();
    
  // Then use it in test
  await page.goto('/dashboard')
    .fill('input[placeholder=email]', user.email)
    .fill('input[placeholder=password]', 'password123')
    .click('button:has-text("Login")');
    
  // Verify seeded content
  await expect(page.locator(`text=${articles[0].title}`)).toBeVisible();
});

The .perform() Pattern

// .perform() executes a sequence of actions
async perform(...actions: (() => Promise<void>)[]): Promise<this> {
  for (const action of actions) {
    await action();
  }
  return this;
}
 
// Usage
await page.goto('/editor')
  .perform(
    () => page.fill('input[title]', 'My Post'),
    () => page.fill('textarea[body]', 'Content'),
    () => page.click('button:has-text("Publish")')
  );

The .verify() Pattern

// .verify() runs assertions
async verify(assertions: () => Promise<void>): Promise<void> {
  await assertions();
}
 
// Usage
await page.goto('/article/my-article')
  .verify(async () => {
    await expect(page.locator('h1')).toHaveText('My Article');
    await expect(page.locator('.author')).toHaveText('testuser');
  });

Complete 2-Line Test

test('complete user flow', async ({ page, testData }) => {
  // Arrange and Act
  await page.goto('/editor')
    .as(testData.user().withDefaults().create())
    .perform(ArticleForm.create()
      .withTitle('My New Post')
      .withDescription('Short summary')
      .withBody('Great content here')
      .withTags(['testing'])
      .fill()
    )
    .click('button:has-text("Publish")');
 
  await page.verify(async () => {
    await expect(page.locator('h1')).toHaveText('My New Post');
  });
});

What We've Learned

  • Implemented .as() for local inline seeding
  • Created FluentPage wrapper
  • Added .perform() and .verify() methods
  • Removed the need for bulky shared setup hooks

Next: Putting it all together for true 2-line tests.


Previous: Fluent Form Builders: The FormBuilder Pattern Next: Putting It Together: 2-Line E2E Tests That Stay Honest ->