Photo by <a href=”https://unsplash.com/@christopher__burns?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Christopher Burns</a> on <a href=”https://unsplash.com/photos/8KfCR12oeUM?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>
Photo by Christopher Burns on Unsplash

Fishery factories with GraphQL, NodeJS and TypeScript in Playwright tests

Gaia Gonen
Venn Engineering Blog
5 min readFeb 15, 2023

--

A little while ago, we worked on a new project, a remix-based administration web app.

One of our concerns when working was creating the tests. We decided to use Playwright as our testing library for our e2e tests.

As we are working with a complex schema, the Arrange part of our E2E tests was a little complicated and included creating entities in our DB using our GraphQL API.

One glaring problem we faced was the need to share those GraphQL mutations between tests.

When we were about to tackle this, we wanted to keep two things in mind:

  1. Preserve type safety
  2. Create sharing ability that will require as few changes as possible (and that those changes will be unlikely to break other tests)

We also broke down the code we need to share into three main points:

The actual call to our API

const PostFactoryCreatePostDocument = graphql(`
mutation PostFactoryCreatePost(
$data: PostCreateInput!
) {
createPost(data: $data) {
id
}
}
`);

const client = await initGQLClient();
const { createPost } = await client.request(
PostFactoryCreatePostDocument,
{ // here we pass the data }
);

This is where we create the GraphQL client and execute a mutation.
This code will always look the same per entity.

The creation of the entity data

const data: PostCreateInput = {
userId: 'connectedUserId',
title: faker.random.words(5),
content: faker.random.words(42),
publish: true
}

One of the more cumbersome parts of creating a test entity is the need to fake the data for the mutation. Sometimes, our entities also include connections to other entities, which are required in the input, and we will need to create first.

While most of this data will always be faked, we sometimes need to override it for a specific test.

The return fields

Every time we create an entity, we need some of its data to check against our tables/forms.

While some of the return data we need will always be the same, some fields we request are specific to our test.

Solution: using Fishery.

After experimenting with a few different ways to share those GraphQL mutations, we found Fishery, in their own words:

Fishery is a library for setting up JavaScript objects for use in tests and anywhere else you need to set up data. It is loosely modeled after the Ruby gem, factory_bot.

We decided to give it a try, as it gives nice features for what we need and was built with TS in mind.

Lets first look at how a test looked like before we incorporated Fishery:

const PostTestCreatePostDocument = graphql(`
mutation PostFactoryCreatePost(
$data: PostCreateInput!
) {
createPost(data: $data) {
id
}
}
`);

const PostTestCreateUserDocument = graphql(`
mutation PostTestCreateUser(
$data: UserCreateInput!
) {
createUser(data: $data) {
id
}
}
`)
test('a user can update the post content', async ({
editPostPage,
postsTablePage,
}) => {
// Arrange
const client = await initGQLClient();

const userInputData: UserCreateInput = {
firstName: faker.random.word(),
lastName: faker.random.word(),
email: faker.internet.email(),
}

const { createUser: user } = await client.request(
PostTestCreateUserDocument,
{ userInputData }
);
const postInputData: PostCreateInput = {
userId: user.id,
title: faker.random.words(5),
content: faker.random.words(42),
publish: true
}

const { createPost: post } = await client.request(
PostTestCreatePostDocument,
{ postInputData }
)
const newContent = faker.random.words();
await editPostPage.goto({ id: post.id });
await editPostPage.assertHeading();
await editPostPage.assertContent({ text: post.text! });
// Act
await editPostPage.fillContent({ text: newContent });
await editPostPage.submit();
// Assert
await postsTablePage.assertHeading();
await editPostPage.assertSuccessMessage();
const row = await postsTablePage.getRow({ name: newContent });
await row.assertContentCell({ text: newContent });
});

And now lets look at how it looks after we added the factory:

test('a user can update the post content', async ({
editPostPage,
postsTablePage,
}) => {
// Arrange
const post = await postFactory.create();
const newContent = faker.random.words();
await editPostPage.goto({ id: post.id });
await editPostPage.assertHeading();
await editPostPage.assertContent({ text: post.text! });
// Act
await editPostPage.fillContent({ text: newContent });
await editPostPage.submit();
// Assert
await postsTablePage.assertHeading();
await editPostPage.assertSuccessMessage();
const row = await postsTablePage.getRow({ name: newContent });
await row.assertContentCell({ text: newContent });
});

Lets dive in to how we used factories.

First we need to define a new factory for our entity, and pass it the type from our schema. This will make sure the return value of the factory is typed.

export const postFactory = Factory.define<Post>(
return {
// here we must return every field that is required by the Post type
id: uuid(),
userId: uuid(),
title: faker.random.words(5),
content: faker.random.words(42),
publish: true
}
)

By returning the fields from the factory, we encapsulate the logic for creating fake data for the post.

At this point we can use the factory to get a mock object of the entity with a simple call to build: postFactory.build()
We can also override, or add optional fields for post easily: postFactory.build({ title: ‘specific title’, imageId: ‘1234’ });

Those overrides are also typed:

// error - Type 'string' is not assignable to type 'boolean'.
postFactory.build({ publish: 'random string' });

Second we are going to use the onCreate hook to actually create the entity in the DB. the hook receives the mock as a parameter out of the box, and we are using it to fill the createInput data.
Lastly we will return the mock, only overriding the ID field with the actual id we got back from GraphQL.

const PostFactoryCreatePostDocument = graphql(`
mutation PostFactoryCreatePost(
$data: PostCreateInput!
) {
createPost(data: $data) {
id
}
}
`);

export const postFactory = Factory.define<Post>(
({ onCreate, sequence }) => {
onCreate(async (post) => { // post contains the return values from our mocked post
const client = await initGQLClient();

// here we deconstruct verbosely,
// some inputs may be different than what they are in the mocked post
const data: PostCreateInput = {
title: post.title
content: post.content
publish: post.publish
userId: userFactory.create().id // this will create the connected entity first
};
const { createPost } = await client.request(
PostFactoryCreatePostDocument,
{
data,
},
);

// here we override the mocked id with the real id from the created post
return { ...post, id: createPost.id };
});
return {
id: uuid(),
userId: uuid(),
title: faker.random.words(5),
content: faker.random.words(42),
publish: true
}
)

As you can see in the example test, the call to onCreate is straightforward: postFactory.create()

Same as in the build method, we can pass the create overrides. postFactory.create({ title: ‘this title will actually be saved in the DB’ })

So how did this actually solve our three parts of the shared code?

  1. The GraphQL call: The creation of the GQL client, and the mutation code, are encapsulated in the create method and will likely never change.
  2. The creation of entity data: Now the field fakers are encapsulated. If we need to override something, we can pass it to the create or build methods, but we don’t need to check and understand anything else about the creation of the entity most of the time. This code will barely change, and if it does, we need to change it only in one place.
  3. The return fields: This was one of our main pain points, and we solved it beautifully by changing our thinking: Instead of receiving the fields from GraphQL and then returning them, we ask only for the ID and return the mocked fields we already created.

Final thoughts

While in this article, I’m talking about our leading problem, which was to share the mutations between tests; Fishery got many more features worth exploring like extending factories, associations and transient parameters.

Also, have you noticed we used custom page objects in the test and wondered what they are? The factories are only a part of the strategy we used for our E2E tests. If you want to learn more about it checkout this article

--

--