Transcript
00:00 When writing your custom test utilities and fixtures, you are effectively creating your own APIs on top of your testing framework. And a great way to approach API design is usage first, or in case in TypeScript, type first. So I will head to my test setup file and create a new interface called fixtures.
00:17 I will use it to describe the types for my custom fixtures and tests. So here I want to create a new fixture called createMockCart, and for it to be a function. It will accept the argument items, which will be an array of cart item, and return the whole cart object.
00:33 But I don't want to provide the entire cart item every time I have to use this fixture. Instead, I would like to provide it with a subset of data specific only to the behaviors I am testing. To do that, I will annotate this cart item as partial. Now that the type definitions for this fixture are ready, I can implement it.
00:52 To do that, I will import the function test from vTest and call test.extend. I will use this extend method to extend the default test context and provide it with my custom fixtures. But first, I will provide it with a type argument, my fixtures interface, and then an object as an argument.
01:10 In this object, I will describe the implementation for my fixtures. So basically my createMockCart fixture, and it will accept two arguments. The first one is the test context, and the second is a special use function. Before we implement this fixture, we have to take a moment to talk about the fixture's lifecycle.
01:26 When you write fixtures in vTest, they have three main phases. The first one would be the preparation, or the before phase, where you can establish any side effects that your fixture needs, for example, spawning an HTTP server. Then the second phase will be the use phase. This is where you provide the exact literal value of your fixture in tests.
01:44 This is what your test will actually get when they grab your fixture from the test context. And the last phase will be the cleanup, or the after phase. This is basically where you clean up after all of the side effects you introduced in the before phase. Depending on the design of your fixture, you might not need all of these phases, but you would usually keep the use phase to provide the value.
02:02 In the case of our createMockCart function, it doesn't need any preparation and any cleanup, so I will just use the use function and provide it with the implementation of the fixture. You can see TypeScript suggesting this type, and this is exactly the call signature that I've defined here in fixtures.
02:18 So I'll provide it here, items, and I would have to return the cart. The implementation of this fixture is not correct, though, because it expects the whole cart object, but there's the argument it accepts only a partial of cart item.
02:34 So I would have to fill in the missing data with random data. To do that, I will use a library called FakerJS. First, I will go to my terminal and install it, npm install fakerjs-faker.
02:47 Once I'm done, I will import the faker object from this package, fakerjs-faker, and I will use it to populate my cart items with properties that they're missing. First, I will map over the provided items,
03:03 and here I will spread the provided item, and I will populate the missing properties. So let's quickly go through what's happening here. For the ID property of my cart item, I will use this faker.string.ulid. For the name, I will use a special commerce product name, which is very nice. I'm staying domain-specific.
03:21 For the price, I will generate the random number between 1 and 25, and then for quantity, I will do the same, just the range will be different from 1 to 10. Once I have this, you can see that TypeScript is happy because I'm providing the full cart item and then allowing any overwrites specific to my test cases.
03:38 Now that I've implemented this fixture, I want to use it in my tests, but calling test.extend doesn't automatically expose this fixture in place. Instead, it returns me a new test function that I have to use in my tests instead. You can notice that now if I want to have the same experience and refer to my function as test,
03:56 now I have this namespace conflict because I cannot have a variable test while I'm also importing test from vtest. What I will do, I will import this test from vtest as an alias test base and also update my usage over here, and then export my custom test function so I'm able to reference it in my tests.
04:15 In fact, let's do just that. Let's head to this cart utils test. Instead of this global test function from vtest, the default one, I will import the test function that I prepared from my setup. I will go to my setup, from test.extend, and I will import the test function.
04:32 Once I do that and go to this test context, I can see that now createMockCart exists here, and this is exactly the same function that I've defined in my test setup. So now I can model this particular test case using my custom fixture.
04:46 To do that, I will create a variable called cart and assign it the result of calling createMockCart. In here, I want to reproduce a scenario where my cart has exactly two items. So I'll have item number 1 and item number 2. And I will provide it with the exact cart item price and quantity that I need to test this behavior.
05:05 So price 5, quantity 10, and then price 8, quantity 4. And now all that's left is to test this getTotalPrice function. I will call expect getTotalPrice, provide it with my mock cart, and write the assertion that I expect. In this case, it will be 82.
05:22 Now let's verify that this test is passing by running npm test in the terminal, and we can see that the total price of this cart is exactly what I expect. So let's summarize what we did here. In this test extend module, which is actually not the test setup, I'm not referencing as the setup anywhere in my vTestConfig. It's just a regular TypeScript module.
05:40 I've described the fixtures that I want on the type level, and then I provided the implementation for those fixtures. Here for my createMockCart, I only awaited the use function that provides the value on the test context and implemented my function here that I'm going to use in tests using Faker to help me with the random but realistic data.
05:59 And then in the test case, I imported from the test context and then modeled the cart state that I need and provided it to my assertion, basically completing the test. There is a quick catch here. You may notice that we're referencing this fixture from the destructured test context.
06:14 But if you happen to assign that context to a variable and then call context.createMockCart, this will not work. The reason for that is that vTest, much like Playwright, uses a proxy over this context object. That proxy helps vTest know if you're referencing specific fixtures or not,
06:31 and you will see why that's important in the next exercise. But for now, keep in mind that if you want to use your custom fixtures, always destructure the test context object. Now, fixtures is an extremely powerful way to address complexity of your tested system and also create the nice testing experience on top of your testing framework.
06:49 Because the goal of fixtures is to lift the complexity from your test cases to your test setup. But sometimes in pursuit of that improvement, we actually end up introducing more complexity to our tests. To prevent that from happening, let's go through some best practices when it comes to fixture design. And we will start from static fixtures.
07:08 In general, I don't recommend using static values as your fixtures. Let me show you what I mean by that. So instead of this createMockCart function, what we could have done is just created a cart object on the fixtures and provided a literal value, the list of cart items. So then in tests, we could have just referenced carts and didn't do this step at all.
07:27 This seems like we did the same thing on the surface, even with fewer lines of code, so it must be good, right? Well, not really. The problem here is that when you look at this assertion, you see that the total price of this cart should be 82. But here's the confusing part. Why 82? Why not 64 or maybe 3?
07:46 Your test doesn't know the input to the system. Instead, it relies on this fixture transitively to provide it to this function and then hope that that fixture defined the right cart items with the correct price and quantity. In other words, this test is non-deterministic. And if you suddenly change your fixture over here,
08:03 you may end up breaking a lot of tests that depend on that implicit default state. So instead, always try to keep the data relevant to the tested behavior straight in the test case, exactly how we did previously. We're still using this utility, this fixture, to help us with random data so we don't have to define all the cart,
08:21 but we're providing it with the exact input information. We can see why the total cart price will be 82 right in the test. This is the right approach to designing fixtures. There are some exceptions to static fixtures. For example, you may be testing a UI that depends on a locale. And that value of that locale is unlikely to change during the test run.
08:40 So you might as well put it in the fixture and gain an easier access to it through the test context. Ultimately, you are the best judge of your API design. Just try to strive towards deterministic and isolated tests. And keep in mind that automated tests is the worst place to get smart. When it comes to dynamic fixtures like we have over here,
08:58 try designing them with the right balance between defaults and the custom behaviors. So here, we don't have to provide the entire cart item. For example, the name is not required because it's not relevant in the context of getTotalPrice function that we're testing. Nailing this balance will allow you to create deterministic and controlled test setup
09:17 while offloading a lot of complexity from your test suite. And this comes in handy all the time because you can use custom fixtures to create virtually any kind of utility from writing temporary files in the file system to spawning HTTP servers or intercepting API calls. Your imagination is the only limit here.