DEV Community

Alex Lohr
Alex Lohr

Posted on

Unit testing with Jest/TypeScript hands-on session

or: how to prepare a hands-on session

I recently had a fun and instructive hands-on session with my colleagues to teach them unit testing with Jest/TypeScript and I wanted to share both the session and its preparation with you. So if you are either interested in preparing a hands-on session for your colleagues or want to learn about some of the more interesting features of Jest/TypeScript, this article could be interesting for you.

Initial thoughts and preparations

You want your attendees to get up and running as quickly as possible, so you should prepare a small package to get them started that contains a small README (in markdown), some task file(s) and as little scaffolding as possible (packages.json, configs) to get them started.

I wanted to set them up with the basics for Jest and TypeScript, so I created a small project:

mkdir jest-hands-on
cd jest-hands-on
yarn init -y -p
yarn add --dev typescript jest @types/jest ts-jest
Enter fullscreen mode Exit fullscreen mode

Since we wanted this session to be about testing, I added a small script to package.json: "scripts: { "test": "jest" }, - it pays to spend some thought on the minimal useful scaffolding for your session.

Lastly, I decided to add a src file that contained functional units for them to test, each commented with the problems I wanted them to solve.

Note to self: you will certainly underestimate the time that it needs to finish all the tasks. That's not because your attendees are worse developers than you. They're probably not yet familiar with the topic and will need some time to get up to speed before jumping in.

My README.md just told people what was what and to run yarn and look at src/testable.ts to find their tasks and required reading materials.

I wanted them to discover some of the great features of Jest:

  • automatically creating a number of tests
  • snapshots and serialization as an aide to spot mistakes and changes (I threw in the XSS task for fun afterwards)
  • custom matchers
  • async testing
  • simple spying and mocking

So I wrote a few functions to test, refining and commenting them in the process of solving my own tasks. This is what testable.ts looked like afterwards:

// Task: create multiple tests at once using it.each
// see https://jestjs.io/docs/en/api#testeachtable-name-fn-timeout
export const add = (...numbers) => {
  return numbers.reduce((sum, item) => sum + item, 0);
};

// Task: create DOM snapshot using expect(...).toMatchSnapshot()
// see https://jestjs.io/docs/en/snapshot-testing
// and don't forget to have a look at the snapshot:
// the snapshot will show a mistake. Fix it and re-run the test
// Task: try to exploit lastPoint in a test for XSS & spy on window.alert to catch it
// see https://jestjs.io/docs/en/jest-object#jestspyonobject-methodname
export const createDom = (lastPoint = 'c') => {
  const container = document.createElement('div');
  container.innerHTML = `<ul><li>a</li><li>b</li></li>${lastPoint}</li></ul>`;
  document.body.appendChild(container);
  container.querySelector('li:nth-child(odd)').setAttribute('test', 'true');
  return container;
};

// Task: write custom matcher for a 32bit number with 11 digits
// Task: use new matcher and expect to validate the config
// see https://jestjs.io/docs/en/expect#expectextendmatchers
const createRandomId = () => (1e16 + Math.random() * 1e15).toString(32);
export const config = {
  id: createRandomId(),
  started: new Date()
};

// Task: write an async test using async/await
// see https://jestjs.io/docs/en/tutorial-async#async-await
// Task: create a new file tester.ts which includes this function from testable.ts
// and mock it to run the callback synchronously
// see https://jestjs.io/docs/en/mock-functions
export const resolveWhenever = () => new Promise((resolve) => setTimeout(resolve, Math.floor(Math.random() * 4e3)));
Enter fullscreen mode Exit fullscreen mode

I also created a minimal tsconfig.json and jest.config.js, but I don't want to bore you with the details, as you can easily create those yourself, since I mostly used default values (just including promises to tsconfig.json#compilerOptions.lib). I zipped the scaffolding files and promptly forgot to include a folder, so I had to warn my attendees to create one for the task, which in hindsight could have been better prepared.

A few hours later, the hands-on session started.

Running into problems

As you might imagine, it did not go as smoothly as I had envisioned. Let's take a look at the issues that popped up.

The first task

I started the session as planned with handing out the zip file and letting them unpack it and install it. My attendees were meant to use it.each(...) with an array of test data to write multiple tests in a single statement. I had thought that this task should have been simple, but had not anticipated a few things:

  1. The complexity of the array structure for it.each became a problem, though one easily fixed by using one array for each test, putting the result first and use the spread operator for the summands.
  2. The use of the spread operator confused the hell out of my colleagues who seemed to expect to call add(...) with an array instead of multiple arguments.
  3. Using a variable number of arguments made the ability to format the test string useless, which led to further confusion of my colleagues who initially believed it to be their fault this did not work.

Note to self: Solutions that seem obvious to you may not be as obvious for your attendees. Reflect on what seems obvious to you and prepare to give hints. If you want to add those to your tasks in a way not to be visible instantly, use base64 encoding to make them unreadable.

After giving a few small hints, the solution looked something like this:

describe("add", () => {
  it.each([[6, 1, 2, 3], [998, 999, -1, 1, -1], [33, 1, 1, 2, 3, 5, 8, 13]])(
    "it will add up the numbers",
    (expected, ...numbers) => {
      expect(add(...numbers)).toBe(expected);
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

The second task

The second task contained a script that injected elements into the document. Its first part was meant to show the value of snapshots in Jest and it went surprisingly smooth. The solution looked like this:

describe("createDom", () => {
  it("will create a serialized snapshot of a DOM", () => {
    expect(createDom()).toMatchSnapshot();
  });
});
Enter fullscreen mode Exit fullscreen mode

Now the snapshot showed readily that the last point of the list was not in an li-Element of its own due to it not being opened:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`createDom will create a serialized snapshot of a DOM 1`] = `
<div>
  <ul>
    <li
      test="true"
    >
      a
    </li>
    <li>
      b
    </li>
    c
  </ul>
</div>
`;
Enter fullscreen mode Exit fullscreen mode

Can you spot it too? Yes, looking at your snapshots and/or its changes is what makes them so valuable. The Jest team at Facebook spends a lot of time fine-tuning their serializers, so repay them the favor by making good use of them.

But there was a second part: I thought it would be funny to abuse Jest to catch XSS attacks (XSS is an abbreviation for cross-site-scripting: the ability to let external code run in the context of your side, usually added from form parameters, cookies or similar channels like, in this rather contrived case, function parameters). Not everyone of my attendees had even heard of XSS attacks, so I probably should have added another hint, for example an XSS payload and the way to trigger it in the jsDOM that comes with Jest.

Note to self: you can easily get sidetracked by stuff that you yourself find interesting, but is not within the topic of your hands-on session. Try to focus!

After about 10 minutes of explanations, most attendees had a solution like this one:

describe("createDom", () => {
  // ...
  it("will (not?) allow XSS payloads", () => {
    const alertSpy = jest.spyOn(window, "alert").mockImplementation();
    const XSSPayload = '<span onmouseover="window.alert(1)"></span>';
    const container = createDom(XSSPayload);
    // simulate DOM event
    const ev = document.createEvent("HTMLEvents");
    ev.initEvent("mouseover", false, true);
    container.querySelector("span").dispatchEvent(ev);
    // add .not to fail if alertSpy was called
    expect(alertSpy).toHaveBeenCalled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Usually, our XSS payload would contain some CSS to make sure that a mouseover event always triggers by layering it over the screen. Since we selected it manually to create the event, this was not necessary in this case.

The third task

In the third task, there was a config object to be tested that had varying properties. If the included matchers do not suffice, Jest allows you to extend them with your own.

Luckily, writing such a custom matcher in Jest is simple. You call expect.extend({…}) with an object containing your matcher, which is just a function that gets the expected value and whatever other parameters are thrown at it.

The difficulty of this task this time stemmed from TypeScript, because the definitions of @types/jest are not magically adding these extensions to its type declarations, so you have to do that yourself in order to not be stopped by type errors. One thing easily overlooked is that the first parameter is added implicitly by expect, so it doesn't appear in your declarations.

Since this complexity was part of the topic, I thought I shouldn't add a hint in this case, but in the end, I gave up and sent them an example TypeScript declaration for the custom matcher, very much like in the following solution:

expect.extend({
  toBeRandomId(expected: string) {
    const pass =
      typeof expected === "string" && /^[0-9a-v]{11}$/.test(expected);
    return {
      message: () =>
        `expected ${expected}${
          pass ? " not" : ""
        } to be an 11-digit 32bit lower case random id`,
      pass
    };
  }
});

declare global {
  namespace jest {
    // required for expect(expected).toBeRandomId()
    interface Matchers<R> {
      toBeRandomId(): object;
    }
    // required for { id: expect.toBeRandomId() }
    interface Expect {
      toBeRandomId(): object;
    }
  }
}

describe("config", () => {
  // you can check for the property
  it("will contain a random id", () => {
    expect(config.id).toBeRandomId();
  });

  // or the complete object
  it("will contain a random id and the current date", () => {
    expect(config).toEqual({
      id: expect.toBeRandomId(),
      started: expect.any(Date)
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Note to self: You cannot plan for all eventualities. If something does not work as expected, try not to waste too much time on it, like I did.

The fourth task

The last one, I wanted to be a bit of a breather after the more complicated ones, and in hindsight, this went surprisingly well. My attendees should take a function that emitted a Promise that automatically resolved after a random delay and test it in different ways.

Jest will accept Promise as a return value to the test functions and will handle the tests as asynchronous, so the solution was easily found:

describe("runWhenever", () => {
  it("will run after a random time", async () => await resolveWhenever());
});
Enter fullscreen mode Exit fullscreen mode

The second part of this task meant to show the mocking facilities of Jest by mocking away the function we just tested in order to run the tests synchronously, which is another part where Jest shines with ease of use:

// tester.ts
import { resolveWhenever } from "./testable";

export const useResoveWhenever = () =>
  resolveWhenever().then(() => console.log("now"));

// tester.test.ts
jest.mock("./testable", () => ({
  resolveWhenever: () => ({ then: cb => cb() })
}));

import { useResoveWhenever } from "./tester";

describe("tester", () => {
  it("logs after resolve", () => {
    const logSpy = jest.spyOn(console, "log").mockImplementation();
    useResoveWhenever();
    expect(logSpy).toHaveBeenCalled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Unfortunately, we were already over time, so only the faster half of my attendees even got to this task. I had initially tried to wander around the room, helping where I found attendees to be slow. Those attempts to help were stopped by the necessity to help the faster attendees with the bigger issues.

Note to self: Do not attempt to track all attendees, even if there are only very few of them. It makes them nervous and if there are more of them, you can't probably help where it counts anyway.

Next time, I will try to give my attendees a few minutes to solve the task themselves and then create the solution in front of everyone while explaining it for the benefit of those who were not already finished.

Conclusion

From reading this article, you may have gotten the impression that I made every possible mistake. At least I prepared the scaffolding, put some thoughts into the tasks and solved them beforehand to make sure I found the problems before my attendees did. The rest were, as my wife who is a teacher put it, typical mistakes of a trainee teacher and thus easily avoided with a bit more experience.

I hope you found this helpful or at least entertaining. See you in my next post.

Latest comments (0)