Skip to main content
  1. Posts/

Porting a Create React App application to Vite Pt. 2: Unit Testing

·2376 words·12 mins
Project Architecture New Tech configuration testing NodeJS
Tim Goshinski
Author
Tim Goshinski
I code and I know (some) things

For unit tests we will still be using the excellent Testing Library, but we will swap Vitest in place of Jest. Vitest promises Jest compatibility without having to duplicate a bunch of configuration to get Jest to function correctly with a Vite project.

Configure Testing
#

Let’s install the packages that we will need for creating unit tests:

yarn add --dev vitest @vitest/coverage-istanbul jsdom @testing-library/jest-dom \
@testing-library/react @testing-library/react-hooks @testing-library/user-event \
redux-mock-store

# add missing typings to package.json
npx typesync

# install the typings found by typesync
yarn

We can just lift /src/setupTests.ts as-is from our previous project.

Since I like to explicitly separate configurations based on usage I chose the third option from the documentation for creating a vitest.config.ts file. The coverage.include and coverage.exclude values are translated from the jest.collectCoverageFrom section of my CRA project’s package.json . coverage.branches/function/statements were pulled from jest.coverageThreshold.global values in the same file.

file: vitest.config.ts

import { mergeConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      environment: 'jsdom',
      // we do not want to have to import 'expect', etc. on every file
      globals: true,
      setupFiles: './src/setupTests.ts',
      coverage: {
        provider: 'istanbul',
        // if 'false' does not show our uncovered files
        all: true,
        include: ['src/**/*.{ts,tsx}'],
        exclude: [
          'src/@enums/**',
          'src/@interfaces/**',
          'src/@mocks/**',
          'src/@types/**',
          'src/services/**',
          'src/**/index.{ts,tsx}',
          'src/main.tsx',
          'src/routes.tsx',
        ],
        branches: 85,
        functions: 90,
        statements: 90,
      },
    },
  }),
);

The TypeScript compiler will need to know of Vitest’s globals:

file: tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "types": ["vitest/globals"]
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

Define a couple of NPM scripts for convenience:

file: package.json

  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "format": "pretty-quick --staged",
    "format:check": "prettier --check .",
    "format:fix": "prettier --write .",
    "lint": "run-p lint:*",
    "lint:ts": "eslint ./src/**/**.ts*",
    "lint:styles": "stylelint \"./src/**/*.scss\"",
    "fix:styles": "stylelint \"./src/**/*.scss\" --fix",
    "test": "vitest --run",
    "test:cov": "vitest run --coverage",
    "prepare": "husky install"
  },

Finally add our coverage thresholds to the pre-commit hook:

npx husky add .husky/pre-commit "npm run test:cov"

Port a Simple Test
#

For the first test I want to bring over something really simple so I believe my service helpers will be a good place to start. We should be able to get away with only modifying the one line dealing with pulling a value from the .env file:

file: /src/helpers/service.test.ts

import {
  createDeleteRequest,
  createGetRequest,
  createPatchRequest,
  createPostRequest,
  createPutRequest,
  processApiResponse,
  unwrapServiceError,
} from './service';
import FetchMethods from '../@enums/FetchMethods';

import HttpStatusCodes from '../@enums/HttpStatusCodes';
import IApiBaseResponse from '../@interfaces/IApiBaseResponse';
import { GENERIC_SERVICE_ERROR } from '../constants';

const adminApiUri = import.meta.env.VITE_API_URI;
const fakeEndpoint = '/api/22.19/bubbas/burger/barn';

describe('helpers / service', () => {
  describe('createGetRequest', () => {
    it('should generate a Request with a `GET` method', () => {
      const result: Request = createGetRequest(fakeEndpoint);

      expect(result.method).toBe(FetchMethods.Get);
      // there will be an underscore (_) query param appended to the url since we
      //   have cache set to false for all requests
      expect(result.url.startsWith(`${adminApiUri}${fakeEndpoint}`)).toBe(true);
      expect(result.headers.has('Authorization')).toBe(false);
      expect(result.headers.has('Content-Type')).toBe(false);
    });

    ...

    it('should return a standard Error from a custom thrown error', () => {
      const sut = {
        message: 'yeah, no',
        errors: ['you should see me', 'but not me'],
      };
      const methodName = 'unwrapped';

      const serviceError: any = unwrapServiceError(methodName, sut);

      expect(serviceError.message).toBe(`${methodName} error: ${sut.errors[0]}`);
      expect(serviceError.message).not.toBe(`${methodName} error: ${sut.errors[1]}`);
    });
  });
});

Looks like a success:

➜  vite-redux-seed (main) ✗ yarn test
 RUN  v0.24.4 /home/tgoshinski/work/lab/react/vite-redux-seed

 ✓ src/helpers/service.test.ts (15)

Test Files  1 passed (1)
     Tests  15 passed (15)
  Start at  22:24:59
  Duration  2.26s (transform 681ms, setup 217ms, collect 99ms, tests 31ms)


Process finished with exit code 0.

Port Redux Slice Tests
#

May as well start alphabetically with the alerts slice tests. Mocking with Vitest is very similar to mocking in Jest, so there were only a few modifications that needed to be made to get this test working. First we need to import the Mock interface from Vitest and replace all jest.Mock<any, any> with just Mock<any, any>. Next jest.mock('uuid') becomes vi.mock('uuid'):

file: /src/store/slices/alerts.test.ts

import { Mock } from 'vitest';
import { alerts } from './alerts';
import AlertTypes from '../../@enums/AlertTypes';
import IAlert from '../../@interfaces/IAlert';

import { v4 } from 'uuid';
vi.mock('uuid');

describe('store / slices / alerts', () => {
  describe('reducer(s)', () => {
    const initialState: Array<IAlert> = [
      { id: 'foo', type: AlertTypes.Error, text: 'i broke it' },
      { id: 'bar', type: AlertTypes.Info, text: 'it was brokded when I got here' },
      {
        id: 'baz',
        type: AlertTypes.Warning,
        text: "keep your fingers away from Lenny's mouth",
      },
    ];
    const mockedUuid = v4 as Mock<any, any>;
    const mockUuidValue = 'some-unique-guidy-thing';

    it('should remove an alert by id', () => {
      const { removeAlert } = alerts.actions;
      const alertId = 'bar';
      const expected: Array<IAlert> = initialState.filter(a => a.id !== alertId);

      const state = alerts.reducer(initialState, removeAlert(alertId));

      expect(state.some(_ => _.id === alertId)).toBe(false);
      expect(state).toEqual(expected);
    });

    ...

    it('should add a warning alert with a generated uuid', () => {
      mockedUuid.mockImplementationOnce(() => mockUuidValue);
      const { addWarningAlert } = alerts.actions;

      const payload: IAlert = {
        id: 'really-why-do-you-read-these',
        type: AlertTypes.Warning,
        text: 'do not put that in your eye',
      };
      const expected: Array<IAlert> = [...initialState, { ...payload, id: mockUuidValue }];

      const state = alerts.reducer(initialState, addWarningAlert(payload));

      expect(state).toEqual(expected);
    });
  });
});

Run the slice tests:

➜  vite-redux-seed (main) ✗ yarn test
 RUN  v0.24.4 /home/tgoshinski/work/lab/react/vite-redux-seed

 ✓ src/store/slices/alerts.test.ts (5)

Test Files  1 passed (1)
     Tests  5 passed (5)
  Start at  22:47:57
  Duration  2.24s (transform 736ms, setup 195ms, collect 163ms, tests 7ms)


Process finished with exit code 0.

Now we can bring over the rest of the store slice tests:

Port Component Tests
#

The good news is I did not need to modify any of my component tests - they all just worked as originally written for Jest:

Final Sweep
#

Putting it all together let’s commit all of our hard work:

➜  vite-redux-seed (main) ✗ git add -A
➜  vite-redux-seed (main) ✗ git commit -m '✅ unit tests'

> vite-redux-seed@0.1.0 format
> pretty-quick --staged

🔍  Finding changed files since git revision 6560904.
🎯  Found 17 changed files.
✅  Everything is awesome!


 RUN  v0.24.4 /home/tgoshinski/work/lab/react/vite-redux-seed
      Coverage enabled with istanbul

 ✓ src/helpers/service.test.ts (15)
 ✓ src/components/app/AppToasts/Toast/Toast.test.tsx (7) 546ms
 ✓ src/helpers/service.test.ts (15)
 ✓ src/components/app/AppToasts/Toast/Toast.test.tsx (7) 546ms
 ✓ src/components/app/AppToasts/AppToasts.test.tsx (1) 325ms
 ✓ src/pages/Counter/Counter.test.tsx (4) 306ms
 ✓ src/components/app/AppAlerts/Alert/Alert.test.tsx (9) 376ms
 ✓ src/components/app/AppAlerts/AppAlerts.test.tsx (1)
 ✓ src/pages/Users/Users.test.tsx (5) 315ms
 ✓ src/store/slices/user.test.ts (8)
 ✓ src/store/slices/toasts.test.ts (5)
 ✓ src/store/slices/alerts.test.ts (5)
 ✓ src/store/slices/counter.test.ts (3)

Test Files  11 passed (11)
     Tests  63 passed (63)
  Start at  08:42:58
  Duration  11.68s (transform 1.56s, setup 2.98s, collect 6.56s, tests 2.25s)

 % Coverage report from istanbul
------------------------------------|---------|----------|---------|---------|-------------------
File                                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------------------------|---------|----------|---------|---------|-------------------
All files                           |   85.99 |    83.67 |   79.72 |   86.66 |
 src                                |     100 |    93.33 |     100 |     100 |
  helpers                           |     100 |    93.33 |     100 |     100 | 53
 src/components/app/AppAlerts       |     100 |      100 |     100 |     100 |
  AppAlerts.tsx                     |     100 |      100 |     100 |     100 |
 src/components/app/AppAlerts/Alert |   95.23 |      100 |      75 |   95.23 |
  Alert.tsx                         |   95.23 |      100 |      75 |   95.23 | 28
 src/components/app/AppToasts       |     100 |      100 |     100 |     100 |
  AppToasts.tsx                     |     100 |      100 |     100 |     100 |
 src/components/app/AppToasts/Toast |   96.66 |      100 |      75 |   96.66 |
  Toast.tsx                         |   96.66 |      100 |      75 |   96.66 | 31
 src/components/app/Navigation      |       0 |      100 |       0 |       0 |
  Navigation.tsx                    |       0 |      100 |       0 |       0 | 7-8
 src/helpers                        |     100 |      100 |     100 |     100 |
  hooks.ts                          |     100 |      100 |     100 |     100 |
 src/layouts/MainLayout             |       0 |      100 |       0 |       0 |
  MainLayout.tsx                    |       0 |      100 |       0 |       0 | 10-11
 src/pages/Counter                  |     100 |      100 |     100 |     100 |
  Counter.tsx                       |     100 |      100 |     100 |     100 |
 src/pages/FourOhFour               |       0 |        0 |       0 |       0 |
  FourOhFour.tsx                    |       0 |        0 |       0 |       0 | 6-9
 src/pages/NotificationsDemo        |       0 |      100 |       0 |       0 |
  NotificationsDemo.tsx             |       0 |      100 |       0 |       0 | 18-45
 src/pages/Users                    |     100 |    76.92 |     100 |     100 |
  Users.tsx                         |     100 |    76.92 |     100 |     100 | 44-46
 src/store/slices                   |   98.61 |       75 |   97.36 |     100 |
  alerts.ts                         |     100 |      100 |     100 |     100 |
  counter.ts                        |     100 |      100 |     100 |     100 |
  toasts.ts                         |     100 |      100 |     100 |     100 |
  user.ts                           |   96.42 |    66.66 |      90 |     100 | 28,61
------------------------------------|---------|----------|---------|---------|-------------------
ERROR: Coverage for functions (79.72%) does not meet global threshold (90%)
ERROR: Coverage for statements (85.99%) does not meet global threshold (90%)
ERROR: Coverage for branches (83.67%) does not meet global threshold (85%)
husky - pre-commit hook exited with code 1 (error)

ESBuild Problem and Workaround
#

We should have met our coverage thresholds since we have pulled all of the same tests from the previous CRA project, but on the bright side we have confirmed that our pre-commit hook works.

It appears our istanbul directives (ex: /* istanbul ignore file */), for files like NotificationsDemo.tsx are not being honored. Searching the issues tracker on Vitest’s Github I found the issue appears to be ESBuild is stripping those directive comments out when transpiling the file. The workaround that seemed the least invasive to me was explicitly telling ESBuild to preserve those comments by changing /* istanbul ignore file */ to /* istanbul ignore file -- @preserve */.

See files:

With those files being excluded from coverage we should now be well within our coverage thresholds and able to commit our files:

➜  vite-redux-seed (main) ✗ git add -A
➜  vite-redux-seed (main) ✗ git commit -m '✅ unit tests'

> vite-redux-seed@0.1.0 format
> pretty-quick --staged

🔍  Finding changed files since git revision 6560904.
🎯  Found 21 changed files.
✅  Everything is awesome!


 RUN  v0.24.4 /home/tgoshinski/work/lab/react/vite-redux-seed
      Coverage enabled with istanbul

 ✓ src/components/app/AppToasts/Toast/Toast.test.tsx (7) 477ms
 ✓ src/components/app/AppAlerts/Alert/Alert.test.tsx (9)
 ✓ src/components/app/AppToasts/Toast/Toast.test.tsx (7) 477ms
 ✓ src/components/app/AppAlerts/Alert/Alert.test.tsx (9)
 ✓ src/components/app/AppToasts/AppToasts.test.tsx (1) 352ms
 ✓ src/pages/Users/Users.test.tsx (5)
 ✓ src/pages/Counter/Counter.test.tsx (4)
 ✓ src/components/app/AppAlerts/AppAlerts.test.tsx (1)
 ✓ src/helpers/service.test.ts (15)
 ✓ src/store/slices/user.test.ts (8)
 ✓ src/store/slices/toasts.test.ts (5)
 ✓ src/store/slices/alerts.test.ts (5)
 ✓ src/store/slices/counter.test.ts (3)

Test Files  11 passed (11)
     Tests  63 passed (63)
  Start at  09:38:03
  Duration  10.97s (transform 1.82s, setup 3.09s, collect 6.26s, tests 2.06s)

 % Coverage report from istanbul
------------------------------------|---------|----------|---------|---------|-------------------
File                                | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------------------------------|---------|----------|---------|---------|-------------------
All files                           |   98.34 |    87.23 |   95.16 |   98.83 |
 src                                |     100 |    93.33 |     100 |     100 |
  helpers                           |     100 |    93.33 |     100 |     100 | 53
 src/components/app/AppAlerts       |     100 |      100 |     100 |     100 |
  AppAlerts.tsx                     |     100 |      100 |     100 |     100 |
 src/components/app/AppAlerts/Alert |   95.23 |      100 |      75 |   95.23 |
  Alert.tsx                         |   95.23 |      100 |      75 |   95.23 | 28
 src/components/app/AppToasts       |     100 |      100 |     100 |     100 |
  AppToasts.tsx                     |     100 |      100 |     100 |     100 |
 src/components/app/AppToasts/Toast |   96.66 |      100 |      75 |   96.66 |
  Toast.tsx                         |   96.66 |      100 |      75 |   96.66 | 31
 src/helpers                        |     100 |      100 |     100 |     100 |
  hooks.ts                          |     100 |      100 |     100 |     100 |
 src/pages/Counter                  |     100 |      100 |     100 |     100 |
  Counter.tsx                       |     100 |      100 |     100 |     100 |
 src/pages/Users                    |     100 |    76.92 |     100 |     100 |
  Users.tsx                         |     100 |    76.92 |     100 |     100 | 44-46
 src/store/slices                   |   98.61 |       75 |   97.36 |     100 |
  alerts.ts                         |     100 |      100 |     100 |     100 |
  counter.ts                        |     100 |      100 |     100 |     100 |
  toasts.ts                         |     100 |      100 |     100 |     100 |
  user.ts                           |   96.42 |    66.66 |      90 |     100 | 28,61
------------------------------------|---------|----------|---------|---------|-------------------
[main 2da7f0b] ✅ unit tests
 29 files changed, 2902 insertions(+), 34 deletions(-)
 create mode 100644 src/@mocks/users.ts
 create mode 100644 src/components/app/AppAlerts/Alert/Alert.test.tsx
 create mode 100644 src/components/app/AppAlerts/Alert/__snapshots__/Alert.test.tsx.snap
 create mode 100644 src/components/app/AppAlerts/AppAlerts.test.tsx
 create mode 100644 src/components/app/AppAlerts/__snapshots__/AppAlerts.test.tsx.snap
 create mode 100644 src/components/app/AppToasts/AppToasts.test.tsx
 create mode 100644 src/components/app/AppToasts/Toast/Toast.test.tsx
 create mode 100644 src/components/app/AppToasts/Toast/__snapshots__/Toast.test.tsx.snap
 create mode 100644 src/components/app/AppToasts/__snapshots__/AppToasts.test.tsx.snap
 create mode 100644 src/helpers/service.test.ts
 create mode 100644 src/pages/Counter/Counter.test.tsx
 create mode 100644 src/pages/Counter/__snapshots__/Counter.test.tsx.snap
 create mode 100644 src/pages/Users/Users.test.tsx
 create mode 100644 src/pages/Users/__snapshots__/Users.test.tsx.snap
 create mode 100644 src/setupTests.ts
 create mode 100644 src/store/slices/alerts.test.ts
 create mode 100644 src/store/slices/counter.test.ts
 create mode 100644 src/store/slices/toasts.test.ts
 create mode 100644 src/store/slices/user.test.ts
 create mode 100644 vitest.config.ts

Final Thoughts
#

Porting a small demo application was not really difficult and I am happy with the results. I would actually have to tackle a real-world project to determine if Vite is a better alternative for me than Create React App but at least I have determined that I will not have to sacrifice any of the code quality tooling I have been relying on.

If I had to choose something to complain about it would be that Vitest’s integration with WebStorm is not as seamless as Jest’s. Jest’s test runner’s coverage analysis integration is second to none and I really miss that with the Vitest test runner:

displaying WebStorm's `Coverage` window

Note the subtle gutter indicators for covered lines in alerts.ts

The debugger appears to work as well with Vitest as Jest though, so that is a plus:

displaying WebStorm's `Debug` window stepping through a Vitest test

Very similar to debugging tests written with Jest

If Vite continues to gain traction I am sure WebStorm’s tooling will catch up just as it did when newer tech like Tailwind CSS became a hit. I am not a big user of Visual Studio Code so I can not speak to plugin support on that particular IDE. From the command line everything works just as well as using Jest so really it is just a GUI-user’s annoyance more than any kind of hindrance.

Unless Create React App is a hard requirement I will likely give Vite a chance with the next project I get to spin up from scratch. Thank you for stopping by, I hope you found something useful here.

Update 2022-11-28
#

As of WebStorm 2022.3 Vite and Vitest are now fully integrated into the IDE as first class citizens - no more third party plugins needed:

displaying WebStorm's test runner running Vitest tests

WebStorm’s test runner! Yay!