Skip to content
Permalink
Browse files

Add some unit tests, especially snapshot tests for Preact components …

…with CSS-in-JS

Verify config works for CSS snapshots
  • Loading branch information...
jsyang committed Mar 16, 2019
1 parent de6e9a5 commit adb778d3a4ac0d319d880af89b997752de52c98d
@@ -1,9 +1,11 @@
.idea
.DS_Store
node_modules/
node_modules
yarn-error.log
.cache
.env
dist
fresh.js
scripts/fresh*.json
scripts/fresh*.json
.jest-cache
coverage
@@ -6,6 +6,7 @@
"homepage": "https://github.com/jsyang/fresh",
"license": "MIT",
"scripts": {
"test": "jest",
"ts-watch": "tsc --noEmit -w -p .",
"regen-env": "node scripts/generateDotEnv.js",
"db:reset": "node scripts/resetDB.js",
@@ -16,12 +17,19 @@
"cleanup": "git checkout _dev.html ; mv client.*.js fresh.js"
},
"devDependencies": {
"@types/jest": "^24.0.11",
"@types/parcel-env": "^0.0.0",
"css": "^2.2.4",
"cssbeautify": "^0.3.1",
"diffable-html": "^4.0.0",
"history": "^4.7.2",
"jest": "^24.5.0",
"parcel-bundler": "^1.11.0",
"picostyle": "^2.0.1",
"preact": "^8.4.2",
"preact-render-to-string": "^4.1.0",
"preact-router": "^2.6.1",
"ts-jest": "^24.0.0",
"typescript": "^3.2.2",
"unistore": "^3.1.1"
},
@@ -34,5 +42,44 @@
"node-fetch": "^2.3.0",
"promise.prototype.finally": "^3.1.0",
"twilio": "^3.26.1"
},
"jest": {
"cacheDirectory": ".jest-cache",
"automock": false,
"bail": false,
"testPathIgnorePatterns": [
".idea",
".cache",
"dist",
"/node_modules"
],
"collectCoverage": true,
"verbose": false,
"setupFilesAfterEnv": [
"./src/client/jest/setup.ts"
],
"collectCoverageFrom": [
"src/client/components/AdminScreen/**/*.{ts,tsx}",
"!node_modules/**",
"!**/*.d.ts"
],
"clearMocks": true,
"coverageReporters": [
"html"
],
"snapshotSerializers": [
"./src/client/jest/html.ts",
"./src/client/jest/css.ts"
],
"transform": {
".(ts|tsx)": "ts-jest"
},
"testRegex": "spec.(ts|tsx|js)$",
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"json"
]
}
}
@@ -108,6 +108,9 @@ Preact-Router Simplest router, small size
Unistore Easily persist to localStorage , etc.
(state management) Small size, simplest data store
Jest Industry standard unit and integration testing with
(testing library) minimal config for React / alt-React projects
mongoDB via MLab Minimal setup, free hosting, NoSQL
(database) Tried Firebase, bundle was > 1 MB with
the SDK, too much config hassle.
@@ -158,6 +161,25 @@ literals:
}
```

### Snapshot testing with `preact` and `picostyle`

It's not immediately obvious how to test Preact JSX within a Jest snapshot test, especially
when they are styled with Picostyle. However, the great thing about micro-libraries is
the minimal length of their source code:
- preact needs a render to string wrapper for snapshots: `preact-render-to-string`
- picostyle appends a stylesheet to the virtual DOM (VDOM) while generating CSS class names:
to get the CSS rules we just need to read them back out from the VDOM.
This can only be done in sequence (after the `render()`).

```
expect(render(renderable)).toMatchSnapshot();
expect(getCSSRules()).toMatchSnapshot('css');
```

A Jest helper lives inside the setup file for the CSS rules: `getCSSRules()`

See `src/client/components/AdminScreen/TableRow.spec.tsx` for a full example.

## Similar apps / tools

- [Home Routines](https://itunes.apple.com/gb/app/home-routines/id353117370?mt=8)
@@ -0,0 +1,20 @@
import {h} from 'preact';
import {render} from 'preact-render-to-string';

import {RoomSelect} from './RoomSelect';

describe('components/RoomSelect', () => {
it('should match snapshot', () => {
expect(render(
<RoomSelect
rooms={[
{name: 'bed'},
{name: 'bath'},
{name: 'kitchen'}
]}
selected="bed"
onChange={new Function}
/>
)).toMatchSnapshot();
});
});
@@ -6,10 +6,16 @@ const SelectField: any = style('select')({
'font-size': '1em'
});

class RoomSelect extends Component<{ selected: string, onChange: Function }> {
interface IRoomSelectProps {
rooms?: { name: string }[];
selected: string;
onChange: Function;
}

export class RoomSelect extends Component<IRoomSelectProps> {
render() {
const {selected, onChange} = this.props;
const rooms = store.getState().rooms;
const rooms = this.props.rooms || store.getState().rooms;

return <SelectField onChange={onChange}>
{rooms.map(({name}) => {
@@ -0,0 +1,24 @@
import {h} from 'preact';
import {render} from 'preact-render-to-string';

import TableRow from './TableRow';

describe('components/TableRow', () => {
it('should match snapshot', () => {
const renderable = (
<TableRow
row={{
_id: 'abcdef',
room: 'bed',
name: 'clean the floor',
description: 'floor gets really dirty after a party',
isDeep: true
}}
headerKeys={'name,isDeep,description'.split(',')}
/>
);

expect(render(renderable)).toMatchSnapshot();
expect(getCSSRules()).toMatchSnapshot('css');
});
});
@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`components/RoomSelect should match snapshot 1`] = `

<select class="p0">
<option value="bed"
selected="selected"
>
bed
</option>
<option value="bath">
bath
</option>
<option value="kitchen">
kitchen
</option>
</select>

`;
@@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`components/TableRow should match snapshot 1`] = `

<tr id="abcdef">
<td contenteditable
data-key="name"
class="p0"
>
bed
</td>
<td contenteditable
data-key="isDeep"
class="p0"
>
clean the floor
</td>
<td contenteditable
data-key="description"
class="p0"
>
Y
</td>
<td contenteditable
class="p0"
>
floor gets really dirty after a party
</td>
<td style="cursor:pointer; text-align:center"
class="p0"
>
</td>
</tr>

`;

exports[`components/TableRow should match snapshot: css 1`] = `
.p0 {
border: 1px solid #aaaaaa;
padding: 0.25em;
}
`;
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`components/AdminScreen should match loading snapshot 1`] = `"Loading rooms and tasks..."`;
@@ -0,0 +1,15 @@
import {h} from 'preact';
import {render} from 'preact-render-to-string';

import {AdminScreen} from '.';

describe('components/AdminScreen', () => {
it('should match loading snapshot', () => {
expect(render(
<AdminScreen
rooms={[]}
tasks={[]}
/>
)).toMatchSnapshot();
});
});
@@ -11,8 +11,8 @@ interface IAdminScreenProps {
tasks: any[];
}

class AdminScreen extends Component<IAdminScreenProps> {
componentWillMount() {
export class AdminScreen extends Component<IAdminScreenProps> {
componentDidMount() {
Promise.all([getAllTasks(), getAllRooms()])
.catch(() => {
clearState();
@@ -0,0 +1,26 @@
const cssBeautify = require('cssbeautify');
const css = require('css');

const CSSBEAUTIFY_OPTIONS = {
indent: ' '
};

export function test(value: any): boolean {
if (typeof value === 'string') {
try {
const ast = css.parse(value);

// Only pass it to the print function if there has been parsed rules, otherwise we end up with <style> tags in JSON.
return (ast && ast.stylesheet && ast.stylesheet.rules.length > 0);
} catch (e) {
return false;
}
} else {

return false;
}
}

export function print(value: string): string {
return cssBeautify(value, CSSBEAUTIFY_OPTIONS);
}
@@ -0,0 +1,9 @@
const toDiffableHtml = require('diffable-html');

export function test(value: any): boolean {
return typeof value === 'string' && value[0] === '<';
}

export function print(value: string): string {
return toDiffableHtml(value);
}
@@ -0,0 +1,2 @@
(global as any).getCSSRules = () =>
(document.styleSheets[0] as any).cssRules[0].cssText;
@@ -13,7 +13,7 @@ export interface IState {
rooms: any[];
}

const DEFAULT_STATE: IState = {
export const DEFAULT_STATE: IState = {
username: null,
password: null,
isFetching: false,
@@ -11,3 +11,5 @@ interface ITask {
userLastCleaned: string;
timeLastCleaned: number;
}

declare const getCSSRules: Function;
@@ -7,6 +7,7 @@
"alwaysStrict": true,
"removeComments": true,
"moduleResolution": "node",
"esModuleInterop": true,
"allowJs": true,
"jsx": "react",
"jsxFactory": "h",

0 comments on commit adb778d

Please sign in to comment.
You can’t perform that action at this time.