1 | # SPDT - Storybook Puppeteer Declarative Testing
|
2 | <p>
|
3 | <a href="https://app.codeship.com/projects/329679">
|
4 | <img src="https://app.codeship.com/projects/68070880-222e-0137-f223-6a5a7fbcefce/status?branch=master"
|
5 | alt="Codeship Status for sseletskyy/storybook-puppeteer-declarative-testing">
|
6 | </a>
|
7 | <a href='https://coveralls.io/github/sseletskyy/storybook-puppeteer-declarative-testing?branch=master'><img src='https://coveralls.io/repos/github/sseletskyy/storybook-puppeteer-declarative-testing/badge.svg?branch=master' alt='Coverage Status' /></a>
|
8 | <a href="https://www.npmjs.com/package/spdt">
|
9 | <img src="https://img.shields.io/npm/v/spdt.svg"
|
10 | alt="npm version">
|
11 | </a>
|
12 | <a href="https://packagephobia.now.sh/result?p=spdt">
|
13 | <img src="https://packagephobia.now.sh/badge?p=spdt"
|
14 | alt="install size">
|
15 | </a>
|
16 | <a href="https://github.com/sseletskyy/storybook-puppeteer-declarative-testing/blob/master/LICENSE.md">
|
17 | <img src="https://img.shields.io/npm/l/spdt.svg"
|
18 | alt="license">
|
19 | </a>
|
20 | <a href="https://david-dm.org/sseletskyy/storybook-puppeteer-declarative-testing">
|
21 | <img src="https://david-dm.org/sseletskyy/storybook-puppeteer-declarative-testing/status.svg"
|
22 | alt="dependency status">
|
23 | </a>
|
24 | </p>
|
25 |
|
26 | ## TL;DR
|
27 |
|
28 | ```npm i -D spdt```
|
29 | or ```yarn add spdt -D```
|
30 |
|
31 | # Overview
|
32 | Declarative testing of isolated React components using storybook (v4) as a renderer and puppeteer+jest as a test runner.
|
33 |
|
34 | It's not yet another testing framework. It's just the way how to implement DRY (don't repeat yourself) principle for testing React components.
|
35 |
|
36 | * write a fixture - props for React component
|
37 | * add a declaration - a single line which describes what to test
|
38 | * write a simple storybook story
|
39 | * call **npm run spdt** to generate tests and run storybook server
|
40 | * call **npm run spdt:test** to run generated jest tests - puppeteer reads react components in storybook and calls assertions
|
41 |
|
42 | Here is an example.
|
43 |
|
44 | We have a React component to display a list of products.
|
45 | ```javascript
|
46 | // file: src/example/ProductList.jsx
|
47 | const ProductList = ({list}) => (
|
48 | <div>
|
49 | {list.map((item, index) => <div key={index} className="itemElement">{item}</div>)}
|
50 | </div>
|
51 | )
|
52 | export default ProductList
|
53 | ```
|
54 | We have some testing data (fixture) to render: `['apricot', 'banana', 'carrot']`
|
55 | We need to write a test to check that three items (with class .itemElement) will be rendered.
|
56 | Let's write a fixture in such a form:
|
57 | ```javascript
|
58 | // file: src/example/__tests__/ProductList.fixture.js
|
59 | export default {
|
60 | listOfThree: {
|
61 | props: {
|
62 | items: ['apricot', 'banana', 'carrot']
|
63 | },
|
64 | spdt: {
|
65 | checkSelector: {selector: '.itemElement', length: 3} // <----
|
66 | }
|
67 | }
|
68 | }
|
69 | ```
|
70 | That simple declaration `checkSelector: {selector: '.itemElement', length: 3}` makes **spdt** library to generate a test for you.
|
71 | No need to copy-paste unit tests any more.
|
72 | Just write a test pattern (declaration) once and reuse it over and over (see example **testH1** below)
|
73 |
|
74 | You can add as many fixtures as you want:
|
75 | ```javascript
|
76 | // file: src/example/__tests__/ProductList.fixture.js
|
77 | export default {
|
78 | listOfThree: {
|
79 | props: {
|
80 | items: ['apricot', 'banana', 'carrot']
|
81 | },
|
82 | spdt: {
|
83 | checkSelector: {selector: '.itemElement', length: 3} // <----
|
84 | }
|
85 | },
|
86 | listOfTwo: {
|
87 | props: {
|
88 | items: ['orange', 'tangerine']
|
89 | },
|
90 | spdt: {
|
91 | checkSelector: {selector: '.itemElement', length: 2} // <----
|
92 | }
|
93 | }
|
94 | }
|
95 | ```
|
96 |
|
97 |
|
98 | The idea behind this module was to make testing of React+D3 components based on fixtures.
|
99 | However **spdt** can speed up testing of any React application
|
100 |
|
101 | ## Here is a short description of the workflow:
|
102 |
|
103 | * create a React component, e.g `Comment.js`
|
104 | * create a fixture file with a set of properties for your component: `Comment.fixture.js`
|
105 | * create a story file `Comment.story.js` which is used by Storybook to generate versions of your component based on fixture file:
|
106 | * run `npm run spdt:generate-story-index` to generate `.spdt/index.js` file for Storybook
|
107 | * add asserts/expectations for the component in fixture file (see examples below)
|
108 | * run `npm run spdt:generate-test-index` to generate `.spdt/test-index.generated.js` file.
|
109 | * run `npm run spdt:generate-tests` to generate test files for each React component (which has story and fixture files), e.g. `Comment.generated.spdt.js`
|
110 | * run Storybook server `npm run spdt:storybook`
|
111 | * run generated tests using jest + puppeteer `npm run spdt:test` (in another terminal tab)
|
112 |
|
113 | ## How to install **spdt**
|
114 |
|
115 | * Install it from npm `npm i -D spdt`
|
116 | * Run initialization `node_modules/.bin/spdt:init` It will copy config files (jest, puppeteer, storybook) to predefined folder (by default `./spdt` )
|
117 | * Copy generated scripts from terminal to your __package.json__ file
|
118 |
|
119 | ```
|
120 | "spdt:generate-story-index": "./node_modules/.bin/spdt:generate-story-index",
|
121 | "spdt:generate-test-index": "./node_modules/.bin/spdt:generate-test-index",
|
122 | "spdt:generate-tests": "./node_modules/.bin/spdt:generate-tests",
|
123 | "spdt:test": "jest --detectOpenHandles --config ./.spdt/jest.spdt.config.js",
|
124 | "spdt:test:chrome": "HEADLESS=false jest --detectOpenHandles --config ./.spdt/jest.spdt.config.js",
|
125 | "spdt:test:chrome:slow": "SLOWMO=1000 HEADLESS=false jest --detectOpenHandles --config ./.spdt/jest.spdt.config.js",
|
126 | "spdt:storybook": "start-storybook -p 9009 -c ./.spdt",
|
127 | "spdt": "npm run spdt:generate-story-index && npm run spdt:generate-test-index && npm run spdt:generate-tests && npm run spdt:storybook"
|
128 | ```
|
129 |
|
130 | ## Which npm modules need to be installed
|
131 | * @storybook/react@5.x (if you still using storybook v4 please use version spdt@1.1.6)
|
132 | * @babel/node@^7.2 (@babel libs are used for generating test files)
|
133 | * @babel/core@^7.3
|
134 | * @babel/plugin-transform-runtime@^7.3
|
135 | * puppeteer@^1.20.0
|
136 | * jest-puppeteer@^4.2.0
|
137 | * react@16.x
|
138 |
|
139 | ## How to use
|
140 |
|
141 | For example you have a react component like this
|
142 |
|
143 | ```
|
144 | // src/components/SimpleComponent+.js
|
145 |
|
146 | import React from 'react'
|
147 |
|
148 | export default function(props) {
|
149 | const { title, children } = props
|
150 | return (
|
151 | <div className="simple-component">
|
152 | {title}
|
153 | <br />
|
154 | {children}
|
155 | </div>
|
156 | )
|
157 | }
|
158 | ```
|
159 |
|
160 | Create a fixture file `SimpleComponent.fixture.js` inside of `__tests__` folder near the component
|
161 |
|
162 | ```
|
163 | // file src/components/__tests__/SimpleComponent.fixture.js
|
164 |
|
165 | export default {
|
166 | fixtureOne: {
|
167 | props: {
|
168 | title: 'Component Title',
|
169 | children: 'Some children components',
|
170 | },
|
171 | spdt: {
|
172 | checkSelector: 'div.simple-component',
|
173 | },
|
174 | },
|
175 | fixtureTwo: {
|
176 | props: {
|
177 | title: 'Another Title',
|
178 | children: 'Some children components',
|
179 | },
|
180 | spdt: {
|
181 | checkSelector: 'div.simple-component',
|
182 | },
|
183 | },
|
184 | }
|
185 | ```
|
186 |
|
187 | Here is a schema of the fixture file
|
188 | ```
|
189 | {
|
190 | [unique name of fixture]: {
|
191 | props: { ... }, // list of all props for your component
|
192 | spdt: { ... }, // list of assertions such as checkSelector, checkAxes, checkBars, checkArcs, etc.
|
193 | },
|
194 | [another fixture]: ...
|
195 |
|
196 | }
|
197 | ```
|
198 |
|
199 | Create a file `SimpleComponent.story.js` inside of `__tests__` folder
|
200 |
|
201 | ```
|
202 | // file src/components/__tests__/SimpleComponent.story.js
|
203 |
|
204 | const { storiesOf } = require('@storybook/react')
|
205 | import SimpleComponent from '../SimpleComponent'
|
206 | import fixtures from './SimpleComponent.fixture'
|
207 |
|
208 | export default (storyGenerator) =>
|
209 | storyGenerator({
|
210 | storiesOf,
|
211 | title: 'SimpleComponent',
|
212 | Component: SimpleComponent,
|
213 | fixtures,
|
214 | })
|
215 | ```
|
216 |
|
217 | Now run `npm run spdt` to call four commands sequencially
|
218 | * `spdt:generate-story-index` - it will generate `./.spdt/index.js` for Storybook
|
219 | * `spdt:generate-test-index` - it will generate a `./.spdt/test-index.generated.js` for TestGenerator
|
220 | * `spdt:generate-tests` - it will generate `<your component name>.generated.spdt.js` files based on pairs (story.js + fixture.js)
|
221 | * `spdt:storybook` - it will run Storybook server, available at http://localhost:9009
|
222 |
|
223 | If everything went well and Storybook started to work you can run generated tests
|
224 | ```
|
225 | npm run spdt:test
|
226 | ```
|
227 |
|
228 | ## example of a complicated component with dependencies such as Redux, Router
|
229 |
|
230 | ```
|
231 | // file src/components/Article/Comment.js
|
232 |
|
233 | import DeleteButton from './DeleteButton'
|
234 | import { Link } from 'react-router-dom'
|
235 | import React from 'react'
|
236 |
|
237 | const Comment = (props) => {
|
238 | const comment = props.comment
|
239 | const show = props.currentUser && props.currentUser.username === comment.author.username
|
240 | return (
|
241 | <div className="card">
|
242 | <div className="card-block">
|
243 | <p className="card-text">{comment.body}</p>
|
244 | </div>
|
245 | <div className="card-footer">
|
246 | <Link to={`/@${comment.author.username}`} className="comment-author">
|
247 | <img src={comment.author.image} className="comment-author-img" alt={comment.author.username} />
|
248 | </Link>
|
249 |
|
250 | <Link to={`/@${comment.author.username}`} className="comment-author">
|
251 | {comment.author.username}
|
252 | </Link>
|
253 | <span className="date-posted">{new Date(comment.createdAt).toDateString()}</span>
|
254 | <DeleteButton show={show} slug={props.slug} commentId={comment.id} />
|
255 | </div>
|
256 | </div>
|
257 | )
|
258 | }
|
259 |
|
260 | export default Comment
|
261 | ```
|
262 |
|
263 | A file `Comment.fixture.js` inside of `__tests__` folder
|
264 |
|
265 | ```
|
266 | // file src/components/Article/__tests__Comment.fixture.js
|
267 |
|
268 | export default {
|
269 | fixture1: {
|
270 | props: {
|
271 | comment: {
|
272 | id: 'id123',
|
273 | body: 'some text',
|
274 | author: {
|
275 | username: 'Author name',
|
276 | image: 'https://i.pinimg.com/originals/b6/89/81/b6898148bfa9df9e67330fca31571f9b.png',
|
277 | },
|
278 | createdAt: 'Sat Aug 25 2018',
|
279 | },
|
280 | slug: 'slug123',
|
281 | currentUser: {
|
282 | username: 'user name',
|
283 | },
|
284 | },
|
285 | spdt: {
|
286 | checkSelector: ['div.card', 'div.card-footer', 'img.comment-author-img']
|
287 | },
|
288 | },
|
289 | }
|
290 | ```
|
291 |
|
292 |
|
293 | A file `Comment.story.js` inside of `__tests__` folder
|
294 |
|
295 | ```
|
296 | import React from 'react'
|
297 | import { Route, BrowserRouter, browserHistory } from 'react-router-dom'
|
298 | import { Provider } from 'react-redux'
|
299 | import reducer from '../../../reducer'
|
300 | import { createStore } from 'redux'
|
301 | import Comment from '../Comment'
|
302 | import fixtures from './Comment.fixture'
|
303 | const { storiesOf } = require('@storybook/react')
|
304 |
|
305 | const Component = (props) => (
|
306 | <div>
|
307 | <Provider store={createStore(reducer)}>
|
308 | <BrowserRouter history={browserHistory}>
|
309 | <Route path="/" component={() => <Comment {...props} />} />
|
310 | </BrowserRouter>
|
311 | </Provider>
|
312 | </div>
|
313 | )
|
314 |
|
315 | export default (storyGenerator) =>
|
316 | storyGenerator({
|
317 | storiesOf,
|
318 | title: 'Comment',
|
319 | Component: Component,
|
320 | fixtures,
|
321 | })
|
322 | ```
|
323 |
|
324 | Run `npm run spdt` and then `npm run spdt:test` in another terminal tab
|
325 | and you should see results of jest test runner
|
326 |
|
327 | ```
|
328 | > npm run spdt:test
|
329 |
|
330 | > react-redux-realworld-example-app@0.1.0 spdt:test .../react-redux-realworld-example-app
|
331 | > jest --detectOpenHandles --config ./.spdt/jest.spdt.config.js
|
332 |
|
333 | Setup Test Environment.
|
334 | PASS src/components/Article/__tests__/Comment.generated.spdt.js
|
335 | Comment - fixture fixture1
|
336 | ✓ should find component matching selector [div.card] 1 time(s) (16ms)
|
337 | ✓ should find component matching selector [div.card-footer] 1 time(s) (8ms)
|
338 | ✓ should find component matching selector [img.comment-author-img] 1 time(s) (4ms)
|
339 |
|
340 | Test Suites: 1 passed, 1 total
|
341 | Tests: 3 passed, 3 total
|
342 | Snapshots: 0 total
|
343 | Time: 1.372s, estimated 2s
|
344 | Ran all test suites.
|
345 | Teardown Puppeteer
|
346 | Teardown Test Environment.
|
347 | ```
|
348 |
|
349 |
|
350 | ## SPDT Declarations
|
351 |
|
352 | Use these declarations as keys in fixtures
|
353 | ```
|
354 | export default {
|
355 | [fixture name]: {
|
356 | props: { ... },
|
357 | spdt: {
|
358 | [declaration name]: [declaration value]
|
359 | }
|
360 | }
|
361 | }
|
362 | ```
|
363 | ### checkSelector
|
364 |
|
365 | Value can be
|
366 | * `string` , e.g. 'div.className'
|
367 | * `object` , e.g. {selector: 'div.className', length: 0}
|
368 | * `array` of strings or objects, e.g. ['div.className', {selector: 'li', length: 5}]
|
369 |
|
370 | This assertion will generate a separate `it` test to check provided selector
|
371 |
|
372 | ```
|
373 | it('should find component matching selector [div.card] 1 time(s)', async () => {
|
374 | const components = await page.$$('div.card')
|
375 | const expected = 1
|
376 | expect(components).toHaveLength(expected)
|
377 | }),
|
378 | ```
|
379 |
|
380 | ### checkSvg
|
381 |
|
382 | Value can be of `Boolean` type
|
383 |
|
384 | * value `true` means that `it` test will be generated to check `svg` tag is present on the page
|
385 | * value `false` means that `it` test won't be generated
|
386 |
|
387 | ```
|
388 | it('should load component as <svg>', async () => {
|
389 | const component = await page.$('svg')
|
390 | expect(component._remoteObject.description).toMatch('svg') // eslint-disable-line no-underscore-dangle
|
391 | })`
|
392 | ```
|
393 |
|
394 | ### checkAxes
|
395 |
|
396 | Value can be of `Number` type
|
397 |
|
398 | * value means the number of expected axes of D3 chart based on selector `g.axis`
|
399 |
|
400 | ```
|
401 | it('should have ${checkAxes} axes', async () => {
|
402 | const axes = await page.$$('g.axis')
|
403 | const expected = ${checkAxes}
|
404 | expect(axes).toHaveLength(expected)
|
405 | })`
|
406 | ```
|
407 |
|
408 | ### checkBars
|
409 |
|
410 | Value can be of `Boolean` type
|
411 |
|
412 | * value `true` means that `it` test will be generated to check selector `rect.bar` has found elements as many as in array `fixture.props.data`
|
413 | * value `false` means that `it` test won't be generated
|
414 |
|
415 | ```
|
416 | it('should have ${checkBarsValue} bars according to fixture data', async () => {
|
417 | const bars = await page.$$('rect.bar')
|
418 | const expected = ${checkBarsValue} // fixture.props.data.length
|
419 | expect(bars).toHaveLength(expected)
|
420 | })`
|
421 | ```
|
422 |
|
423 | ### checkArcs
|
424 |
|
425 | Value can be of `Boolean` type
|
426 |
|
427 | * value `true` means that `it` test will be generated to check selector `path.arc` has found elements as many as in array `fixture.props.data`
|
428 | * value `false` means that `it` test won't be generated
|
429 |
|
430 | ```
|
431 | it('should have ${checkArcsValue} arcs according to fixture data', async () => {
|
432 | const arcs = await page.$$('path.arc')
|
433 | const expected = ${checkArcsValue} // fixture.props.data.length
|
434 | expect(arcs).toHaveLength(expected)
|
435 | })`
|
436 | ```
|
437 |
|
438 | ### Custom Declarations
|
439 | When you run initialization `node_modules/.bin/spdt:init`
|
440 | it creates the file *test-declarations.js* and a directory *custom-declarations* in the folder *.spdt*
|
441 | Use the example *testH1* declaration as a guideline to add more custom declarations
|
442 |
|
443 | General requirements
|
444 | * The file *test-declarations.js* should export an object.
|
445 | * The key of the object is the name of a custom declaration
|
446 | * The value of the object is a function which takes a fixture and returns a string - generated **it** test for puppeteer+jest environment
|
447 |
|
448 | Example
|
449 | ```javascript
|
450 | const declarationTestH1 = (fixture) => {
|
451 | const { testH1 } = (fixture && fixture.spdt) || {}
|
452 | let selector
|
453 | let value
|
454 | if (typeof testH1 === 'string') {
|
455 | selector = 'h1'
|
456 | value = testH1
|
457 | }
|
458 | if (!selector || !value) {
|
459 | return null
|
460 | }
|
461 | return `
|
462 | it('testH1: should find component matching selector [${selector}] with value ${value}', async () => {
|
463 | const components = await page.$$eval('[id=root] ${selector}', elements => elements.map(e => e.innerText))
|
464 | const expected = '${value}'
|
465 | expect(components).toContain(expected)
|
466 | })`
|
467 | }
|
468 |
|
469 | module.exports = {
|
470 | testH1: declarationTestH1,
|
471 | }
|
472 | ```
|
473 |
|
474 | ## Continuous Integration workflow
|
475 |
|
476 | * make sure you have installed the module `start-server-and-test`
|
477 |
|
478 | ```
|
479 | npm i -D start-server-and-test
|
480 | ```
|
481 | * check `package.json` icludes these script commands
|
482 | ```
|
483 | "spdt:storybook:ci": "start-storybook --ci --quiet -p 9009 -c ./.spdt",
|
484 | "spdt:ci": "npm run spdt:generate-story-index && npm run spdt:generate-test-index && npm run spdt:generate-tests && npm run spdt:storybook:ci",
|
485 | "ci": "start-server-and-test spdt:ci 9009 spdt:test"
|
486 | ```
|
487 | * just run `npm run ci`
|
488 |
|
489 | * module `start-server-and-test` does the magic:
|
490 | * it executes `npm run spdt:ci`
|
491 | * it listens when the port 9009 is available (storybook is up and running)
|
492 | * it runs the tests `npm run spdt:test`
|
493 | * it stops storybook when tests end
|