Get answers to critical business questions fast

home page icon
HOME
/
BLOG
/
Test front-end almost like e2e but on each commit and without browser
Test front-end almost like e2e but on each commit and without browser

Test front-end almost like e2e but on each commit and without browser

Mon, Jan 10, 20228 min read

Category: Code-stories

This article consists of two parts that complement each other making a great tool for testing modern apps in a unique and performant way. The first one focuses on mocking test data while the second one describes the advantages of combining it with React Testing Library.

I introduce you to factory-girl

Mocking data for tests is really important, but it’s not always straightforward. Lucky for us, there’s factory-girl - a really handy library that takes care of this, often time-consuming and rather unpleasant, task. We’ll use factory-girl in a sort of non-ordinary way to make the process of writing tests even simpler and better automated.

What we’ll be testing?

The scope of those tests includes only the frontend part of the application so there won’t be any actual backend working along. Nevertheless, we’ll be able to check if user’s actions like fulfilling inputs and clicking buttons trigger proper API calls and comprehensively verify whether the user interface responds correctly for received data.

A few words about our use case

The codebase that is being used to demonstrate the items discussed in this article uses Redux, Entity Adapters from Redux Toolkit, and Redux Saga. Also instead of classic Rest API, the communication between backend and frontend relays on WebSocket events. 

Of course, these conditions are not obligatory for successfully using the tools mentioned above. You can make different choices and adapt testing techniques to fit your needs as things that’ll occur in our scenario can be easily adapted for various sets of technologies.

In this case, we’ll be testing a team chat application in which one can create a community (kind of a server) and a channel in which people exchange messages.

Each message must have a cryptographic signature matching its sender’s certificate, otherwise, it won’t be considered valid. Because of that, mocking data for tests requires much more effort than just hardcoding utility objects.

So what’s that special about mocking data with factory-girl?

factory-girl builds models with the given data, caring for all its dependencies (corresponding structures), and then inserts it into the test database. As mentioned before, the app uses Redux so to modify the store one needs to dispatch an action. Thankfully, factory-girl comes with the possibility to provide custom adapters for specific model types. In this case, the adapter will take care of our Redux actions.

export class CustomReduxAdapter {
 store: Store;

 constructor(store: Store) {
   this.store = store;
 }

 // Generic type makes sure that no extra properties will be passed   as action's payload
 // at the same time, Partial wrapper allows us to omit some of the properties and let factory-girl take care of them instead of us
 build<T>(Action, payload?: Partial<T>) {
   return Action(payload);
 }

 async save(action) {
   // Instead of 'saving' the model to the db, specific action that stores the data within Redux store will be dispatched
   // also data returned in the form of action's payload may be used for making assertions later in the test
   return this.store.dispatch(action).payload;
 }

 get(payload, attr, _payload) {
   return payload[attr];
 }

 set(props, payload, _payload) {
   Object.keys(props).forEach((key) => {
     payload[key] = props[key];
   });
   return payload;
 }
}

Let’s make use of it and create a function getting test scenario’s store as a parameter, which initializes a new factory object and attaches our newly created adapter to it.

import factoryGirl from 'factory-girl';

export const getFactory = async (store: Store) => {
 const factory = new factoryGirl.FactoryGirl();

 factory.setAdapter(new CustomReduxAdapter(store));

 // Here will come the factory definition

  return factory;
}

Note that factory-girl by default exports a single instance of the class instead of the class itself. That’s because the assumption there is that there’s only one database from which a developer can erase data between tests. In our case, we want to start with a fresh store state in each scenario to be able to test against different behavior based on various data and avoid dependency collisions

Let’s get to work with the data

Now that we have this part set up, it’s time to create the factories! We’re going to need a few things to get started. The first one is a community, the top-level structure to which all the other data will refer. All models our frontend app operates on are stored within Entity Adapters. Users may participate in more than one community. In order to manipulate the data inside it, we use an action that will trigger an adapter's method. This mechanism is going to be the same for basically all the data, which has many advantages but the most important at this point is that it’ll simplify dependency management a lot.

I assume you’re already familiar with Reducers so I won’t repeat the documentation by explaining how to prepare the slice step by step, but I will provide the most essential part of it which is the exact shape of the communities state and the action creator.

 public communities: EntityState<Community> =
   communitiesAdapter.getInitialState();
addNewCommunity: (state, action: PayloadAction<Community>) => {
   communitiesAdapter.addOne(state.communities, action.payload);
 }

Knowing how it looks, we can prepare a factory for the community by naming it and passing the action that updates the store. If the application depended on different state management, we would probably place some model there. Then we specify properties and we give them default values so the data can be generated automatically even if we don’t provide arguments manually when using the factory.

Also as the community without a channel is pointless, we’re gonna use the afterCreate hook to create one immediately after creating a community. Note that factory-girl comes with a few useful methods that makes it easy to keep mocked data unique and well-organized by simultaneously having it automated. For more detailed information, check its official documentation.

factory.define(
   'Community',
   communities.actions.addNewCommunity,
   {
     id: factory.sequence('Community.id', (n) => n),
     name: factory.sequence('Community.name', (n) => `community_${n}`),
   },
   {
     afterCreate: async (
       payload: ReturnType<
         typeof communities.actions.addNewCommunity
       >['payload']
     ) => {
       // Create 'general' channel
       await factory.create('PublicChannel', {
         communityId: payload.id,
         channel: { name: 'general' },
       });
       return payload;
     },
   }
 );

That’s just one method of linking dependencies. Let’s investigate another one. When you join a community, you register your unique identity. It holds your username and all the necessary cryptographic data.As it’s been said, the factory you create must set the default values for the model's parameters. But how to avoid potential errors when there is no valid default value to be set yet? The assoc method comes in handy.

factory.define(
   'Identity',
   identity.actions.addNewIdentity,
   {
     id: factory.assoc('Community', 'id'),
     nickname: factory.sequence(
       'Identity.nickname',
       (n) => `user_${n}`
     ),
   }
 );

The Identity cannot live without a community. In this example, we associate a community's id field which means creating and storing a new one and then using its values as parameters of the desired model.

Now by mocking the user with a single command, we also create community and public channels under the hood, making sure there will be no problems caused by missing objects.

const john = await factory.create<
 ReturnType<typeof identityActions.addNewIdentity>['payload']
 >('Identity', { nickname: 'john' })

So here comes the real power of this library. By nesting create inside build/create callbacks or using assoc as a default parameter value we not only shorten the path of mocking data but also take care of the whole necessary data structure. 

Here’s a clipping of a store snapshot taken right after performing this single operation. It illustrates how many operations are being performed automatically, making the complete elementary test case setup.

{
   "Communities":{
      "currentCommunity":1,
      "communities":{
         "ids":[
            1
         ],
         "entities":{
            "1":{
               "CA":{
                  "rootObject":{
                     "certificate": here comes cryptographic data,
                     "publicKey":{
                        "algorithm":{
                           "name":"ECDSA",
                           "namedCurve":"P-256"
                        },
                        "type":"public",
                        "extractable":true,
                        "usages":[
                           "verify"
                        ]
                     },
                     "privateKey":{
                        "algorithm":{
                           "name":"ECDSA",
                           "namedCurve":"P-256"
                        },
                        "type":"private",
                        "extractable":true,
                        "usages":[
                           "sign"
                        ]
                     }
                  },
                "rootCertString":"MIIBTDCB8wIBATAKBggqhkjOPQQDAjASMRAwDgYDVQQDEwdaYmF5IENBMB4XDTEwMTIyODEwMTAxMFoXDTMwMTIyODEwMTAxMFowEjEQMA4GA1UEAxMHWmJheSBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABNchlOBfIlJ+W5NLhrdt6yc9h4NYpzThEkk1/lcDcr66xHvpD8i2FAsMyJZQpO1/cVWHPvsSYl1bHd2uJzlZGXGjPzA9MA8GA1UdEwQIMAYBAf8CAQMwCwYDVR0PBAQDAgCGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAKBggqhkjOPQQDAgNIADBFAiEAhfIJ62Ph/X5HcEwYBn+VGyjgUqmWvv2c0Y8YJjeGn/oCIFzpGyHcKo6r8i5RwrjM4nGK82occtSjgahheh38TKFM",
                  "rootKeyString":"MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgiISHy1vlAzfaScRM8WZGJc8wtLvmVXN+K3EYvTES33KgCgYIKoZIzj0DAQehRANCAATXIZTgXyJSfluTS4a3besnPYeDWKc04RJJNf5XA3K+usR76Q/IthQLDMiWUKTtf3FVhz77EmJdWx3dric5WRlx"
               },
               "id":1,
               "name":"community_1",
               "registrarUrl":"ugmx77q2tnm5fliyfxfeen5hsuzjtbsz44tsldui2ju7vl5xj4d447yd:7909"
            }
         }
      }
   },
   "Identity":{
      "identities":{
         "ids":[
            1
         ],
         "entities":{
            "1":{
               "hiddenService":{
                  "onionAddress":"putnxiwutblglde5i2mczpo37h5n4dvoqkqg2mkxzov7riwqu2owiaid.onion",
                  "privateKey":"ED25519-V3:WND1FoFZyY+c1f0uD6FBWgKvSYl4CdKSizSR7djRekW/rqw5fTw+gN80sGk0gl01sL5i25noliw85zF1BUBRDQ=="
               },
               "peerId":{
                  "id":"QmWVMaUqEB73gzgGkc9wS7rnhNcpSyH64dmbGUdU2TM3eV",
                  "privKey":"CAASqAkwggSkAgEAAoIBAQCY2r7s5YlgWXlHuHH4PY/cUik/m7GuWPdTPmmm4QZTr1VSyKgC2AMR45xrcGMjd5SDh1HjzbptJpYfGWO+Sbm6yK7EfxYN8gOXrbo0koKtPH0hrgzus+CqUCAQDE6XWzY5yP7caFt/Rol
ZaBYNcKCWDCHv+bg/87u3MGwwSeaMjYWNAQ5IVWrUFnns8eiyNRhBGrEQZDTyO4X0oMeEkTTABMEJIpge91SWfuYuqltiNdkS9aiYS58F43IBHKKWLc39b3KbiykiG2IjrqVl2aAyb6vSgtiGkwi301jtWEctaDl2JbwZpgldOA83wH2aBPK9N9MaakEYdI2dHVSg8bf9AgMBAAECggEAOH8JeIfyecE4WXDr9wPSC232vwLt7nIFoCf+ZubfLskscTenGb37jH4jT3avvekx5Fd8xgVBNZzAeegpfKjFVCtepVQPs8HS4BofK9VHJX6pBWzObN/hVzHcV/Ikjj7xUPRgdti/kNBibcBR/k+1myAK3ybemgydQj1Mj6CQ7Tu/4npaRXhVygasbTgFCYxrV+CGjzITdCAdRTWg1+H6puxjfObZqj0wa4I6sCom0+Eau7nULtVmi0hodOwKwtmc2oaUyCQY2yiEjdZnkXEEhP1EtJka+kD96iAG3YvFqlcdUPYVlIxCP9h55AaOShnACNymiTpYzpCP/kUK9wFkZQKBgQD2wjjWEmg8DzkD3y19MVZ71w0kt0PgZMU+alR8EZCJGqvoyi2wcinfdmqyOZBf2rct+3IyVpwuWPjsHOHq7ZaJGmJkTGrNbndTQ+WgwJDvghqBfHFrgBQNXvqHl5EuqnRMCjrJeP8Uud1su5zJbHQGsycZwPzB3fSj0yAyRO812wKBgQCelDmknQFCkgwIFwqqdClUyeOhC03PY0RGngp+sLlu8Q8iyEI1E9i/jTkjPpioAZ/ub5iD6iP5gj27N239B/elZY5xQQeDA4Ns+4yNOTx+nYXmWcTfVINFVe5AK824TjqlCY2ES+/hVBKB+JQV6ILlcCj5dXz9cCbg6cys4TttBwKBgH+rdaSs2WlZpvIt4mdHw6tHVPGOMHxFJxhoA1Y98D4/onpLQOBt8ORBbGrSBbTSgLw1wJvy29PPDNt9BhZ63swI7qdeMlQft3VJR+GoQFTrR7N/I1+vYLCaV50X+nHel1VQZaIgDDo5ACtl1nUQu+dLggt9IklcAVtRvPLFX87JAoGBAIBl8+ZdWc/VAPjr7y7krzJ/5VdYF8B716R2AnliDkLN3DuFelYPo8g1SLZI0MH3zs74fL0Sr94unl0gHGZsNRAuko8Q4EwsZBWx97PBTEIYuXox5T4O59sUILzEuuUoMkO+4F7mPWxs7i9eXkj+4j1z+zlA79slG9WweJDiLYOxAoGBAMmH/nv1+0sUIL2qgE7OBs8kokUwx4P8ZRAlL6ZVC4tVuDBL0zbjJKcQWOcpWQs9pC6O/hgPur3VgHDF7gko3ZDB0KuxVJPZyIhoo+PqXaCeq4KuIPESjYKT803p2S76n/c2kUaQ5i2lYToClvhk72kw9o9niSyVdotXxC90abI9",
                  "pubKey":"CAASpgIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCY2r7s5YlgWXlHuHH4PY/cUik/m7GuWPdTPmmm4QZTr1VSyKgC2AMR45xrcGMjd5SDh1HjzbptJpYfGWO+Sbm6yK7EfxYN8gOXrbo0koKtPH0hrgzus+CqUCAQDE6XWzY5yP7caFt/RolZaBYNcKCWDCHv+bg/87u3MGwwSeaMjYWNAQ5IVWrUFnns8eiyNRhBGrEQZDTyO4X0oMeEkTTABMEJIpge91SWfuYuqltiNdkS9aiYS58F43IBHKKWLc39b3KbiykiG2IjrqVl2aAyb6vSgtiGkwi301jtWEctaDl2JbwZpgldOA83wH2aBPK9N9MaakEYdI2dHVSg8bf9AgMBAAE="
               },
               "dmKeys":{
                  "publicKey":"9f016defcbe48829db163e86b28efb10318faf3b109173105e3dc024e951bb1b",
                  "privateKey":"4dcebbf395c0e9415bc47e52c96fcfaf4bd2485a516f45118c2477036b45fc0b"
               },
               "id":1,
               "nickname":"john",
               "userCsr":{
                  "userCsr":"MIIBnDCCAUICAQAwSTFHMEUGA1UEAxM+cHV0bnhpd3V0YmxnbGRlNWkybWN6cG8zN2g1bjRkdm9xa3FnMm1reHpvdjdyaXdxdTJvd2lhaWQub25pb24wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATba0dVJNKOtUEQ2+T7TXo9FX5AuzVC6XN3+roG8rRYLkoxmQfuaI3uut3KrCwzM1J4vt5luc3w3meUOOtfZkb8oIGWMC4GCSqGSIb3DQEJDjEhMB8wHQYDVR0OBBYEFCY4f5ALr0EGRjpWC0sIBMqFBQcQMA8GCSqGSIb3DQEJDDECBAAwFAYKKwYBBAGDjBsCATEGEwRqb2huMD0GCSsGAQIBDwMBATEwEy5RbVdWTWFVcUVCNzNnemdHa2M5d1M3cm5oTmNwU3lINjRkbWJHVWRVMlRNM2VWMAoGCCqGSM49BAMCA0gAMEUCICo6rvO+q7+i3dXI5bMmYBx4wG/IZeuECuzY9cKev9nhAiEAv3gztKB+jwWrZBAnvDUBV6bPW+fMU8PJHHKLVjqPj10=",
                  "userKey":"MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgAWbXg1i5FlR7J58nzXkMfBg9zlUlPkQjcxu+yNCNXZ2gCgYIKoZIzj0DAQehRANCAATba0dVJNKOtUEQ2+T7TXo9FX5AuzVC6XN3+roG8rRYLkoxmQfuaI3uut3KrCwzM1J4vt5luc3w3meUOOtfZkb8",
                  "pkcs10": here comes cryptographic data
               },
"userCertificate":"MIIB7DCCAZECBgF+NF74+TAKBggqhkjOPQQDAjASMRAwDgYDVQQDEwdaYmF5IENBMB4XDTEwMTIyODEwMTAxMFoXDTMwMTIyODEwMTAxMFowSTFHMEUGA1UEAxM+cHV0bnhpd3V0YmxnbGRlNWkybWN6cG8zN2g1bjRkdm9xa3FnMm1reHpvdjdyaXdxdTJvd2lhaWQub25pb24wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATba0dVJNKOtUEQ2+T7TXo9FX5AuzVC6XN3+roG8rRYLkoxmQfuaI3uut3KrCwzM1J4vt5luc3w3meUOOtfZkb8o4GgMIGdMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgCOMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAPBgkqhkiG9w0BCQwEAgQAMBQGCisGAQQBg4wbAgEEBhMEam9objA9BgkrBgECAQ8DAQEEMBMuUW1XVk1hVXFFQjczZ3pnR2tjOXdTN3JuaE5jcFN5SDY0ZG1iR1VkVTJUTTNlVjAKBggqhkjOPQQDAgNJADBGAiEAjuCeua8wiu3gBDmSQuaMDzinrl5rkJrznay1VHFX0FUCIQDHuXO2SB2wFzuQ6a51NTPzSMLVJsqXCh7vmeIMWzx/Ow=="
            }
         }
      }
   },
   "Users":{
      "certificates":{
         "ids":[
            "BNtrR1Uk0o61QRDb5PtNej0VfkC7NULpc3f6ugbytFguSjGZB+5oje663cqsLDMzUni+3mW5zfDeZ5Q4619mRvw="
         ],
         "entities":{
           "BNtrR1Uk0o61QRDb5PtNej0VfkC7NULpc3f6ugbytFguSjGZB+5oje663cqsLDMzUni+3mW5zfDeZ5Q4619mRvw=": here comes cryptographic data
         }
      }
   },
   "PublicChannels":{
      "channels":{
         "ids":[
            1
         ],
         "entities":{
            "1":{
               "id":1,
               "currentChannel":"general",
               "channels":{
                  "ids":[
                     "general"
                  ],
                  "entities":{
                     "general":{
                        "name":"general",
                        "address":"general",
                        "owner":"john"
                     }
                  }
                },
               "channelMessages":{
                  "ids":[],
                  "entities":{}
               }
            }
         }
      }
   }
}

Stay tuned for part II!

Now that we’ve spent so much time preparing the mocks, you’ll probably want to put it into action. Stay tuned and look out for the second part of the article, in which we’ll share some of our experience of using React Testing Library.

Wiktor Sieprawski
Wiktor Sieprawski

Software Engineer

Get the latest technology insights on our blog

Recent posts