DEV Community

Kazuhito Higashioka
Kazuhito Higashioka

Posted on

Unlocking Test Data Efficiency in React

When I was writing tests in previous React projects, I often faced this problem where "test data" was duplicated and, in some cases, had different attribute values, especially when testing different scenarios. For example, let's say I am writing a spec where a user can create a post and has a write access and another test to verify that the user cannot create a post when no write access is given. The specs will look as follows:

describe('user', () => {
  let handlers = []
  let user

  describe('has write posts permission', () => {
    beforeAll(() => {
      user = {
        id: 1000,
        name: 'John Doe',
        permissions: ['posts.write'],
      }

      handlers = [
        http.get('/me', () => {
          return HttpResponse.json(user)
        }),
      ]

      server.use(...handlers)
    })

    test('can create post', async () => {
      render(<CreatePostPage />)

      fireEvent.change(
        screen.getByRole('textbox', { name: /title/i }),
        {
          target: { value: 'My awesome post' },
        },
      )

      fireEvent.click(
        screen.getByRole('button', { name: /create post/i }),
      )

      expect(
        await screen.findByText(/post created/i)
      ).toBeInTheDocument()
    })
  })

  describe('has no write permission', () => {
    beforeAll(() => {
      user = {
        id: 1000,
        name: 'John Doe',
        permissions: [],
      }

      handlers = [
        http.get('/me', () => {
          return HttpResponse.json(user)
        }),
      ]

      server.use(...handlers)
    })

    test('cannot create post', async () => {
      render(<CreatePostPage />)

      expect(
        await screen.findByText(/the page you're trying access has restricted access./i)
      ).toBeInTheDocument()
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

Okay, the spec got too long. But let's focus only on this code block:

user = {
  id: 1000,
  name: 'John Doe',
  permissions: ['posts.write'],
}
Enter fullscreen mode Exit fullscreen mode

So you can imagine that this code will be common "arrange" data since it defines the currently logged-in user and is necessary for those specs that require logged-in user information. This can appear in multiple places in my spec files. Let's say I need this in the following specs: create_post.test.tsx, update_post.test.tsx, delete_post.test.tsx, view_post.test.tsx, and more! What if there is a new feature requirement where I need to add a new attribute to the user? Does that mean I also need to update the mentioned spec files above?

The solution

So how can we solve this problem? At the top of my mind, I can think of a couple of solutions.

User as module

One possible solution is we can extract the user object and put it in a separate module and then import it on the specs that it is needed.

// testUtils/fixtures/user.js

export const user = {
  id: 1000,
  name: 'John Doe',
  permissions: ['posts.write'],
}
Enter fullscreen mode Exit fullscreen mode

and then in the spec, we can just import it.

...
import { user } from 'testUtils/fixtures/user'

...
  beforeAll(() => {
    handlers = [
      http.get('/me', () => {
        return HttpResponse.json(user)
      }),
    ]

    server.use(...handlers)
  })
...
Enter fullscreen mode Exit fullscreen mode

But remember, we need two variants of user. One that has posts.write and the other one that does not have. So we can modify it into:

export const user_with_posts_write_access = {
  id: 1000,
  name: 'John Doe',
  permissions: ['posts.write'],
}

export const user_without_permissions = {
  id: 1000,
  name: 'John Doe',
  permissions: [],
}
Enter fullscreen mode Exit fullscreen mode

You might be thinking, why not just copy the user object and override the permissions attribute:

...
import { user } from 'testUtils/fixtures/user'

...
  beforeAll(() => {
    const user_without_permissions = {
      ...user,
      permissions: [],
    }

    handlers = [
      http.get('/me', () => {
        return HttpResponse.json(user_without_permissions)
      }),
    ]

    server.use(...handlers)
  })
...
Enter fullscreen mode Exit fullscreen mode

Yes, you are right. This works too! In fact this also leads me to the second solution I have in mind.

Factory

So in this solution, the pattern is similar to the first solution however, instead of object, we use a function to "build" our user.

// testUtils/fixtures/user.js

export function createUser(attributes = {}) {
  return {
    id: 1000,
    name: 'John Doe',
    permissions: ['posts.write'],
    ...attributes,
  }
}
Enter fullscreen mode Exit fullscreen mode

As you may notice, the createUser allows us to override specific attributes that we need. E.g.

createUser()
// Returns
// {
//   id: 1000,
//   name: 'John Doe',
//   permissions: ['posts.write']
// }

createUser({ permissions: [] })
// Returns
// {
//   id: 1000,
//   name: 'John Doe',
//   permissions: []
// }

createUser({ id: 1001, name: 'Jane Doe' })
// Returns
// {
//   id: 1001,
//   name: 'Jane Doe',
//   permissions: ['posts.write'],
// }
Enter fullscreen mode Exit fullscreen mode

And the difference on the spec may look like:

describe('user', () => {
  let handlers = []
  let user

  describe('has write posts permission', () => {
    beforeAll(() => {
-     user = {
-       id: 1000,
-       name: 'John Doe',
-       permissions: ['posts.write'],
-     }
+     user = createUser()

      handlers = [
        http.get('/me', () => {
          return HttpResponse.json(user)
        }),
      ]

      server.use(...handlers)
    })

    test('can create post', async () => {
      render(<CreatePostPage />)

      fireEvent.change(
        screen.getByRole('textbox', { name: /title/i }),
        {
          target: { value: 'My awesome post' },
        },
      )

      fireEvent.click(
        screen.getByRole('button', { name: /create post/i }),
      )

      expect(
        await screen.findByText(/post created/i)
      ).toBeInTheDocument()
    })
  })

  describe('has no write permission', () => {
    beforeAll(() => {
-     user = {
-       id: 1000,
-       name: 'John Doe',
-       permissions: [],
-     }
+     user = createUser({ permissions: [] })

      handlers = [
        http.get('/me', () => {
          return HttpResponse.json(user)
        }),
      ]

      server.use(...handlers)
    })

    test('cannot create post', async () => {
      render(<CreatePostPage />)

      expect(
        await screen.findByText(/the page you're trying access has restricted access./i)
      ).toBeInTheDocument()
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

Furthermore, if we want to have "traits"-based data. E.g. we want to create user based on role, we can do something like:

// testUtils/fixtures/user.js

export const USER_TRAITS = {
  ADMIN: "admin",
  AUTHOR: "author",
  READER: "reader",
}

export function createUser(trait = USER_TRAITS.READER, attributes = {}) {
  let roleBasedAttributes = {}

  if (trait == USER_TRAITS.ADMIN) {
    roleBasedAttributes = {
      role: 'Admin',
      permissions: ['posts.write', 'posts.read'],
    }
  }

  if (trait == USER_TRAITS.AUTHOR) {
    roleBasedAttributes = {
      role: 'Author',
      permissions: ['posts.write'],
    }
  }

  if (trait == USER_TRAITS.READER) {
    roleBasedAttributes = {
      role: 'Reader',
      permissions: ['posts.read'],
    }
  }

  return {
    id: 1000,
    name: 'John Doe',
    ...roleBasedAttributes,
    ...attributes,
  }
}
Enter fullscreen mode Exit fullscreen mode

And then when we implement it:

import { createUser, USER_TRAITS } from 'testUtils/fixtures/user.js'

createUser()
// Returns
// {
//   id: 1000,
//   name: 'John Doe',
//   role: 'Reader',
//   permissions: ['posts.read']
// }

createUser(USER_TRAITS.ADMIN, { name: 'Jane Doe' })
// Returns
// {
//   id: 1000,
//   name: 'Jane Doe',
//   role: 'Admin',
//   permissions: ['posts.write', 'posts.read']
// }
Enter fullscreen mode Exit fullscreen mode

Conclusion

In both solutions, we eliminate the problem of having user data hardcoded all over the specs. In case we want to add new attributes to support new feature request, we know that there is only one place to change it.

I'm aware that factory pattern is not a new pattern in fact I discover this when I was working on a Ruby on Rails project. At the time, it was my first experience working on Ruby on Rails, and I was given a task (feature request). After writing the business logic, I need to write specs and that was the time I understand the factory pattern and how it is effective in our team. Which by then we adopted on the frontend team!

Top comments (0)