Testing remix app with playwright

Lora Vinogradov
Venn Engineering Blog
4 min readFeb 15, 2023

--

Creating a comprehensive and effective end-to-end test suite for a project can be a challenging task. It requires significant time and effort to ensure that all relevant functionalities and scenarios are properly covered.

When we started developing a new remix project, we decided to define a few conventions that will help us create and keep a well-structured reliable e2e test suite. In this article, we will introduce some of them.

Following a test files structure convention

Our test file structure will mimic the application structure. And since we are talking about a remix application, the test files will mimic the routes directory of the app.

Let’s look at the routes structure of a sample app:

The sample app contains a list of posts, “edit” and “create” post forms.

-posts
-$id
-edit.tsx
-index.tsx
-new.tsx

Following our convention, the structure of the test files will be:

-posts
-edit.spec.ts
-index.spec.ts
-new.spec.ts

This approach ensures a developer has a clear vision of what should be tested when testing a new feature.

Populating test data

Populating test data before the test can be tricky.

Our app fetches data from graphql API, and each test requires lots of data to be prepared before the test. Some entities have associations that should be handled too.

We can end up with a great amount of setup before every test suite, and we also need to share the setup between tests.

As a solution, we use Fishery to share setup between tests in an elegant way. Check out this post to read more about our implementation.

Using page object model pattern

Playwright recommends using the page object model pattern as a best practice.
The page object model is an automation testing pattern that allows us to encapsulate test logic and reuse code.

Let’s consider this example:

test('Should create a post', async ({
page,
}) => {
const text = 'test';
await page.goto('/post/new');

await expect(
await this.page.getByRole('heading', { name: 'Create New Post' }),
).toBeVisible();

await page.getByLabel('Post').fill(text);

await page
.getByLabel('Browse your files and choose')
.setInputFiles(`${__dirname}/img-test.jpg`);

await page.getByRole('button', { name: 'Save' }).click();
});

As we can see this test is not very comprehensive: we would need to dive into each selector to understand what’s going on in case of failure.
Let’s see how we can refactor it using the page object model pattern.

Here we are creating the CreatePostPage fixture that we will use later in our test:

export class CreatePostPage {
async goto() {
await this.page.goto(`post/new`);
}

async fillContent({ text }: { text: string }) {
await this.fillTextField({ text, label: 'Post' });
}

async setImage() {
await this.getByLabel('Browse your files and choose')
.setInputFiles(`${__dirname}/img-test.jpg`);});
}

async submit() {
await this.page.getByRole('button', { name: /Save/i }).click();
}

async assertHeading() {
await expect(
await this.page.getByRole('heading', { name: 'Create New Post' }),
).toBeVisible();
}
}

To make the CreatePostPage instance accessible in any test, we should extend the playwright’s base test:

extended-test.ts

export const test = base.extend<MyFixtures>({
createPostPage: async ({ page }, use) => {
const createPostPage = new CreatePostPage(page);
await use(createPostPage);
}
});

Now our test will look like following:

import { test } from './extended-test'

test('Should create a post', async ({
createPostPage,
}) => {
const text = 'test';
await createPostPage.goto();
await createPostPage.assertHeading()
await createPostPage.fillContent();
await createPostPage.setImage();
await createPostPage.submit();
});

This way we’ve encapsulated our test logic, and we can add levels of abstraction, reuse selectors and write cleaner tests.
And we clearly understand what the test is trying to check.

Choosing the right selectors

The most resilient way to use selectors is by relying on accessibility labels.
Consider this example:

This combobox doesn’t have a label.
So at first glance we can decide to use a placeholder selector to query for that element:

await page.getByPlaceholder('Search to select a resident')

But the placeholder selectors are not the most optimal selector, since placeholders tend to change.
A better approach will be to add an aria-label on the combobox input:

<Combobox.Input
aria-label='users-list'
placeholder='Search to select a resident'
...
/>

And now we can edit the selector:

await page.getByRole('combobox', {
name: 'users-list'
});

Testing one test case at a time

We’ve decided to keep our tests lean as possible by limiting each test case to testing just one thing at a time.
Let’s say we want to test the edit post page.
Our edit form contains content, title, and image.
We want to test the ability to change each one of them.
So this test will contain three test cases:


test('a user can edit post title', async ({
editPostPage,
}) => {
...
});

test('a user can edit post content', async ({
editPostPage,
}) => {
...
});

test('a user can edit post image', async ({
editPostPage,
}) => {
...
});

The advantage of this approach is that we can easily see the reason for a failing test without the need to deep dive into the test.

Following these conventions improved our ability to troubleshoot, maintain, and write tests.
This keeps the test logic simple, but allows us to maintain a high level of coverage.

That’s all for now, I hope you will find this article valuable when planning your e2e testing.

--

--