Loading
Current section: Assertions 7 exercises
solution

Custom Matchers Solution

Create custom Vitest matchers like .toMatchSchema() with Zod for cleaner, type-safe tests and extend expect directly in setup.

Loading solution

Transcript

00:00 This to match schema matcher doesn't really exist now, so let's implement it. I'll go to the vtest.setup.ts file and implement my custom matchers here, because they're part of my test setup. It's only fitting that I put them in the setup file. I will start by creating a new interface called custom matchers.

00:20 This interface will accept one type argument, let's call it matcher results, and by default it will be of any type. I will use this interface to describe the type signatures for all my custom matchers, where the keys here will be the names of my matchers and the values their types. In this case, I have a toMatchSchemaMatcher

00:38 that will be a function. It will accept the schema argument, and this will be a type schema from Zot, because I'm using Zot to describe my schemas here. You may be using a different library, so you can adjust it appropriately. As a return type, I will use this matcher result, so from here, from the type argument. Now let's break down what's actually

00:56 happening here. By defining this interface, we're letting Vitest know that it will have this toMatchSchemaMatcher like this, according to the key of this interface. The value type here, this call signature, will be the call signature of the matcher.

01:12 Here, we'll be expected to pass a Zot schema, and that will be validated on the type level, which is very nice. To make sure that our matcher has correct return type, we are providing this matcher result type from Vitest as the return type of our matcher. This way, if you, let's say, are asserting on a promise, this could be a promise, you will use a resolve chaining,

01:31 and now Vitest will correctly annotate your custom matcher that it in fact returns a promise that you have to await. And that's achieved by drilling this matcher result type from the type argument to the return type of your matcher. On its own, this interface is just description for

01:45 us, so it's time for us to tell Vitest about it. To do that, we'll have to augment the default type definitions in Vitest. We can do that by calling declare module Vitest, and then redefining here the interface called matchers. To redefine it, we'll make it extend our custom matchers like this,

02:05 and also provide our matcher result type. We can provide it like that. This will map this type argument from the matchers type in Vitest onto our custom matchers interface. At this point, if we take a look at the expect function, we will already see that it recognizes the two match schema.

02:21 This is our custom matcher, and it complains that we're not providing the valid result schema. This is really nice. This means that our custom matcher exists on the type level. But if we try to call this in test, we will get a runtime exception because we haven't actually implemented this function. It doesn't exist really on runtime. To implement custom matchers, we have

02:39 to extend the default expect function. We can do that by calling expect dot extend, and then provide here an object which, similar to this custom matchers interface, will be the object that describes the implementations of all our custom matchers. In this case, to match schema, we can

02:56 see TypeScript auto-completion, which is very nice. This implementation will be a little different from the call signature we described here, because we described the call signature for the usage service in tests, and here we have to implement it. It will be slightly different. It will receive

03:10 two arguments, the received value and the schema, which is our expected value. If we take a look at how the expect function is called, it will give us the right side value as a received value, and the left side, the expected schema, as the schema argument. Now that Vitest gives us two values,

03:30 we can implement proper schema parsing and validation. To do so, let's actually apply the given schema to the received value. We will create a variable called result and assign it to the result of calling schema safe parse on the received value, and then based on the outcome of this parsing, we will return a different matcher result. So if the parsing was unsuccessful, so

03:49 success was false, we will return one result, and if it was successful, we will return a different result. Vitest expects your custom matchers to return a certain special object that represents the result of applying that matcher, and that object has a few keys. For example, pass will determine whether

04:06 this assertion passes. So if parsing was unsuccessful, we should set pass to false. Next, you should provide the message key, that is a function, and it returns a string. Vitest will print this string whenever this matcher result is printed. So in this case, when schema parsing fails, and we can say does not match the schema. And to make the debugging

04:26 experience even better, you can make Vitest print this nice diff between the expected and the actual results. So we can provide the actual result as received, and the expected result result error dot format. This will be a human-friendly formatted error that Zot gives us as a result of parsing.

04:44 And to improve the debugging experience even further, we can pretty print this received object by using a built-in utilities here, by accessing this utils, and then print received function, and providing our received value here as an argument. This will make sure that Vitest prints our received value in the same consistent way as it normally would with any other matcher.

05:04 Now the only thing left to do is to describe the matcher result when the given object actually matches the schema. So this is this return statement. We can provide pass as true, message as matches the schema, and actual using this utils print received

05:24 with the received argument. You might be wondering why we're providing the message here. This matcher result means a successful parsing, right? The reason for that is because you can have inversed assertions or negative assertions in Vitest. So if you do something like expect data

05:40 not to match schema and the value, Vitest will flip this assertion upside down and this matcher will be used and its corresponding message as well. So keep that in mind. Before we run our test and verify that our custom matcher is actually working, let's not forget to include

05:57 this test setup file in our Vitest config. So I'll head to vitest.config.ts, here to this define config call, go to test object and define setup files. In this area, I will provide a path to my setup file, so vitest setup.ts. Once this is done, Vitest will pick up that module and actually

06:14 extend the default matchers with our custom ones. Let's verify that by running the test. So let's head back to the test. We have it reading over here and you can already see how type safe matchers pay off because I'm accidentally passing the user object while I should be passing a Zot schema. So

06:29 let's fix that by passing a Zot schema, this user schema. Now open the terminal and run npm test and you can see the test passing. This means that it actually applies this matcher to our user to the actual data and validates that it matches the user schema. We can quickly confirm this by

06:47 modifying the schema. Let's say the id should be a number and save it and we can see the test failing because the user no longer matches the schema. And if we scroll up here, we can see that yes, we received this object. This is the result of us printing this actual key over here using the Vitest utilities. And then we can see that the expected value of the Zot error is also printed.

07:07 We included it over here and it lets us know that the id property was expected to be a number, but we received a string. There is one detail I want you to pay attention to. Notice that extending the expect function doesn't really return you a custom expect that you should import in using

07:22 your tests like you did with a test context. Instead, Vitest adds your custom matchers in place and automatically puts them on the default expect. So you can keep using it as you normally would. For example, here we're using it as a global function and it still understands that this to match schema matcher actually exists and it can call it correctly. Creating custom

07:42 matchers is a great way to add additional custom experiences to your testing framework. They allow you to shift focus from the implementation details of your assertions to what actually matters, your expectations towards the system. And once again, push any complexity that might come with that to where it belongs in the test setup.