SVG + Jest = Pain
When it comes to testing components with images, be prepared for a bit of pain.
Recently, I spent a whole day to understand how it works in Jest.
I understood the following:
- we can transform FILES with specific extensions (not components)
- we can transform all images as we like, but most often people transform images
into the names of these images in order to get something like
<img src="image.svg" />
in the test
- jest don't understand non-js assets (without custom things)
Problem
Let's describe an example that will return an error if we don't add an svg transformer to jest.config.js
.
I will use svg-jest library (I will describe it later with more details)
import vectorUpImg from './assets/icons/vector-up.svg';
test('Renders svg as img src', () => {
/* ... */
render(<img src={vectorUpImg} />);
/* ... */
});
The above example returns an error like:
({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,global,jest){<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
SyntaxError: Unexpected token '<'
> 7 | import vectorUpSvg from 'assets/icons/v2/plain/vector-up.svg';
OK. Let's add a custom transformer.
I will describe it later with more details, just know that the transformer will transform (lol)
our svg into a string like vector-up.svg
and we'll get in the console:
√ Renders svg as img src (55 ms)
console.log
<body>
<div>
<img
src="vector-up.svg"
/>
</div>
</body>
Ok, test passed. But...
What if we want to use svg as component?
import { ReactComponent as VectorUpComponent } from './assets/icons/vector-up.svg';
test('Renders svg as a ReactComponent. El by querySelector', () => {
const { container } = render(<VectorUpComponent />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});
The above example returns an error like:
Warning: React.jsx: type is invalid -- expected a string (for built-in components)
or a class/function (for composite components) but got: undefined.
You likely forgot to export your component from the file it's defined in,
or you might have mixed up default and named imports.
We can change the transformer
(not add another one because transformers are for file extensions, we can't add two different transformers for one ".svg" extension),
So now the transformer will parse the incoming content and create something like an svg module, so in our test we get:
√ Renders svg as a ReactComponent. El by querySelector (48 ms)
console.log
<body>
<div>
<svg
data-jest-file-name="vector-up.svg"
data-jest-svg-name="vector-up"
data-testid="vector-up"
/>
</div>
</body>
Coooool, nuh? No... If we now try to pass svg to img src, we will get this:
console.log
<body>
<div>
<img
src="[object Object]"
/>
</div>
</body>
Awful... But today I found the solution. @ChromeQ
made a fix for the svg-jest
library and now everything works fine, but you need to add some config until the owner of the
svg-jest updates the package
or merges the PR by @ChromeQ or PR by me.
So, lets workaround with this...
My workaround
My jest.config.js
:
transform: {
...
'^.+\\.svg$': '<rootDir>/src/tests/transformers/svg.js',
...
},
Transformer src/tests/transformers/svg.js
:
I added the changes made by @ChromeQ, did a little refactoring, and added a few docs - PR.
const path = require('path');
/**
* This function build module.
*
* Changes by https://github.com/ChromeQ:
* - module.exports.default = ${functionName};
* + module.exports.__esModule = true;
* + module.exports.default = '${pathname}';
*
* Object props is a more extensible solution when we have a lot of props
* @param {Object} props
* @param {string} props.functionName
* @param {string} props.pathname
* @param {string} props.filename
*
* @function {(functionName, pathname, filename) => string} buildModule
* @param {string} props
* @return {string} string module
*/
const buildModule = props => {
const {
filename,
functionName,
pathname,
} = props;
return `
const React = require('react');
const ${functionName} = (props) =>
{
return React.createElement('svg', {
...props,
'data-jest-file-name': '${pathname}',
'data-jest-svg-name': '${filename}',
'data-testid': '${filename}'
});
}
module.exports.__esModule = true;
module.exports.default = '${pathname}';
module.exports.ReactComponent = ${functionName};
`;
};
/**
* This function creates a function name
* @function {(base) => string} createFunctionName
* @param {string} base
* @return {string} string module
*/
const createFunctionName = base => {
const words = base.split(/\W+/);
/* here I refactored the code a bit and replaced "substr" (Deprecated) with "substring" */
return words.reduce((identifier, word) => identifier + word.substring(0, 1).toUpperCase() + word.substring(1), '');
};
/**
* This function process incoming svg data
* @function {(contents, filename) => string} processSvg
* @param {string} contents - your svg. String like "<svg viewBox="..."><path d="..."/></svg>"
* @param {string} filename - full path of your file
* @return {string} string module
*/
const processSvg = (contents, filename) => {
const parts = path.parse(filename);
if (parts.ext.toLowerCase() === '.svg') {
const functionName = createFunctionName(parts.name);
return buildModule({
filename: parts.name,
functionName,
pathname: parts.base,
});
}
return contents;
// for Jest28 it has to return an object with `code`
// return { code: contents };
};
module.exports = { process: processSvg };
My test:
import React from 'react';
import { render, screen, } from '@testing-library/react';
import vectorUpImg from './assets/icons/vector-up.svg';
import { ReactComponent as VectorUpComponent } from './assets/icons/vector-up.svg';
test('Renders svg as img src', () => {
const { container } = render(<img src={vectorUpImg} />);
const img = container.querySelector('img');
expect(img).toBeInTheDocument();
screen.debug();
});
test('Renders svg as a ReactComponent', () => {
const label = 'icon-vector-up';
const { getByLabelText } = render(<VectorUpComponent aria-label={label} />);
expect(getByLabelText(label)).toBeInTheDocument();
screen.debug();
});
Console happily returns:
√ Renders svg as img src (46 ms)
√ Renders svg as a ReactComponent (7 ms)
console.log
<body>
<div>
<img
src="vector-up.svg"
/>
</div>
</body>
at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)
console.log
<body>
<div>
<svg
aria-label="icon-vector-up"
data-jest-file-name="vector-up.svg"
data-jest-svg-name="vector-up"
data-testid="vector-up"
/>
</div>
</body>
at logDOM (node_modules/@testing-library/dom/dist/pretty-dom.js:90:13)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
One more thing:
This library (as described here) assigns some attributes:
- data-jest-file-name: The name of the file (e.g. 'some-image.svg')
- data-jest-svg-name: Only the name portion of the file (e.g. 'some-image')
- data-testid: Same as data-jest-svg-name, but works with @testing-library/react getByTestId()
So if you want to find the rendered element in your test by attribute you have to pass in some other attributes.
In my example above, I passed the aria-label
attribute and then found my svg at that label.
Or you can find it with data-testid
, initialized by svg-jest depends on your filename.
Or you can find it with querySelector
, as in the example below:
test('renders svg as a ReactComponent', () => {
const { container } = render(<VectorUpComponent />);
const svg = container.querySelector('svg');
expect(svg).toBeInTheDocument();
});