Linting and Testing in Vite
28. October, 2023 • 7 min read • Study
The parts react-scripts took with it
In my previous article I swapped react-scripts out for Vite and the app came back up, faster than before. What didn't come back up was everything react-scripts was quietly doing on the side.
Linting, formatting and tests were all bundled into that one dependency, with configuration you never saw and mostly never needed to. Uninstall it and they leave together. That’s the honest cost of the migration, and it’s the reason people abandon it halfway through.
The good news is it’s an afternoon of work, once, and afterwards the configuration is yours.
ESLint
Start with the Vite plugin, which runs the linter as part of the dev server so mistakes appear in the browser overlay rather than only in CI:
npm install vite-plugin-eslint --save-devimport eslint from 'vite-plugin-eslint';
export default {
// ...
plugins: [react(), eslint()],
};Then ESLint itself and its config generator:
npm install eslint --save-dev
npm init @eslint/configThe generator asks a handful of questions and writes an .eslintrc.js that does not quite work. Here’s mine after fixing it:
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: [
'eslint:recommended',
'standard-with-typescript',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
],
overrides: [
{
env: {
node: true,
},
files: ['.eslintrc.{js,cjs}'],
parserOptions: {
sourceType: 'script',
},
},
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: './tsconfig.json',
},
plugins: ['react'],
rules: {
'@typescript-eslint/semi': 'off',
},
settings: {
react: {
version: 'detect',
},
},
};Two of those lines are the ones worth pointing at, because without them you get errors that look like your code is broken when it isn’t.
parserOptions.project is required by standard-with-typescript. It turns on the rules that need type information, and the parser can’t do that without knowing which tsconfig.json you mean. Leave it out and every lint run fails with a wall of text about the rule requiring parser services, on files that are perfectly fine.
plugin:react/jsx-runtime switches off react/react-in-jsx-scope. Since React 17 the new JSX transform means you don’t import React just to write JSX, and without this line the linter demands an import on every component file. I’ve seen people add hundreds of pointless imports rather than one line of config.
Prettier
The npm init @eslint/config pattern doesn’t carry over to Prettier, there’s no init command. Install it and write the config file yourself:
npm install prettier eslint-config-prettier --save-dev
touch .prettierrc.yamlprintWidth: 80
tabWidth: 2
useTabs: false
semi: true
singleQuote: true
trailingComma: none
bracketSpacing: true
bracketSameLine: false
arrowParens: avoidIf you’re copying an older config from somewhere, note that jsxBracketSameLine became bracketSameLine back in Prettier 2.4 and no longer exists in Prettier 3. Prettier just ignores the key it doesn’t recognise, which is a pleasant way to spend twenty minutes wondering why your JSX is being formatted the way you asked it not to be.
eslint-config-prettier is the other half. It turns off the ESLint rules that disagree with Prettier about whitespace, so the two tools stop rewriting each other’s output. Put it last in extends:
extends: [
'eslint:recommended',
'standard-with-typescript',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'prettier',
],Order matters here. Last wins, and prettier needs to win.
Tests
Now the part I have opinions about.
The obvious choice for a Vite project is Vitest. It reads your existing vite.config.ts, so your aliases, your plugins and your SVG imports already work, and the API is close enough to Jest that most test files run unchanged. If you’re starting fresh, use it. I would.
Jest is still what I reach for when the project has an existing suite, custom matchers and a CI pipeline built around it, because “close enough to Jest” is doing real work in that sentence and I’d rather not find the gaps on a deadline. So, Jest:
npm install jest ts-jest @types/jest jest-environment-jsdom --save-dev// jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleDirectories: ['node_modules', 'src'],
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/__mocks__/file-mock.js',
'\\.(css|less|scss)$': '<rootDir>/__mocks__/style-mock.js',
},
};npx ts-jest config:init generates a bare version of this if you’d rather start from a stub.
The moduleNameMapper entries exist because Jest has no idea what to do with a .png or a .css import. Vite handles those; Node does not. Both mappings need to point at a module that exports something harmless:
// __mocks__/file-mock.js
module.exports = 'test-file-stub';// __mocks__/style-mock.js
module.exports = {};I’ve seen these mapped at setupTests.ts instead, and it does technically work, in the sense that requiring a file that exports nothing gives you nothing. It also means every stylesheet import in your app pulls in your entire test setup, and when that setup grows a global mock or a timer, the reason your test suite got strange will not be anywhere near where you’re looking.
setupTests.ts has an actual job of its own:
// src/setupTests.ts
import '@testing-library/jest-dom';Note that moduleDirectories includes src, which is how Jest resolves the same import Button from 'components/button' paths that vite-tsconfig-paths handles for the build. Two tools, two configurations, same aliases. Keeping them in step is genuinely annoying and is another point in Vitest’s favour.
Leftovers
A few things react-scripts left in the project that no longer belong:
web-vitalsandreportWebVitals. CRA generatedsrc/reportWebVitals.tsand called it from the entry file. Unless you were actually sending those numbers somewhere, uninstall the package, delete the file and remove the import.- Browser targets. Your
package.jsonstill has abrowserslistfield, but Vite’s build target lives invite.config.tsand doesn’t look at it. Installbrowserslist-to-esbuildand it converts the list you already have into esbuild targets, so you keep one source of truth instead of two lists that drift. - Everything else.
npx npm-check-updateswill show you what’s out of date,ncu -uwrites the new ranges. Worth a look every few months rather than once a year in a panic.
The finished files
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import viteTsconfigPaths from 'vite-tsconfig-paths';
import svgrPlugin from 'vite-plugin-svgr';
import eslint from 'vite-plugin-eslint';
import browserslistToEsbuild from 'browserslist-to-esbuild';
// see more at https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), viteTsconfigPaths(), svgrPlugin(), eslint()],
build: {
target: browserslistToEsbuild(),
},
});// package.json
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"scripts": {
"start": "vite",
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview",
"lint": "eslint src/**/*.{ts,tsx}",
"format": "prettier --write src",
"test": "jest"
},
"browserslist": {
"production": [">0.2%", "not dead", "not op_mini all"],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/jest": "^29.5.6",
"@types/node": "^20.8.9",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.2.14",
"@typescript-eslint/eslint-plugin": "^6.9.0",
"@vitejs/plugin-react": "^4.1.0",
"browserslist-to-esbuild": "^1.2.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-standard-with-typescript": "^39.1.1",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-n": "^16.2.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.33.2",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.0.3",
"ts-jest": "^29.1.1",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-svgr": "^4.1.0",
"vite-tsconfig-paths": "^4.2.1"
}
}Only react and react-dom are real dependencies. Everything else builds, lints or tests the thing and belongs in devDependencies, which the CRA-generated file never got right either.
Where this leaves me
The whole setup is now four config files I can read in five minutes, instead of one dependency I couldn’t change. That was the point of the exercise.
What I’m less pleased about is having Jest and Vite each resolving modules their own way. It works today, and it’s exactly the sort of duplication that quietly rots. The next project I start goes on Vitest.
‘Till next time!