├── .gitignore ├── .npmrc ├── .travis.yml ├── .vscode └── settings.json ├── README.md ├── actions └── index.js ├── apps ├── todo-app.js └── todo.html ├── components ├── hello-world.js ├── pico-world.js ├── server-todos.js ├── todo-item.js └── todo-list.js ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ ├── edge-spec.js │ ├── hello-world-component-spec.js │ ├── hello-world-spec.js │ ├── pico-world-spec.js │ ├── server-todos-spec.js │ ├── todo-app-e2e.js │ ├── todo-item-spec.js │ └── todo-list-spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── images └── hello-world.png ├── issue_template.md ├── package.json ├── renovate.json ├── src └── index.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | npm-debug.log 4 | apps/todo-app-bundle.umd.js 5 | apps/todo-app-bundle.umd.js.map 6 | dist 7 | cypress/screenshots/ 8 | bundles 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org/ 2 | save-exact=true 3 | progress=false 4 | package-lock=false 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: 3 | directories: 4 | - ~/.npm 5 | - node_modules 6 | notifications: 7 | email: true 8 | node_js: 9 | - '8' 10 | # install peer dependencies we need before installing 11 | # main dependencies so all get cached 12 | before_install: 13 | - npm install cypress hyperapp 14 | script: 15 | # builds and tests everything 16 | - npm run build 17 | - npm run test:ci:record 18 | after_success: 19 | - npm run semantic-release || true 20 | branches: 21 | except: 22 | - /^v\d+\.\d+\.\d+$/ 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": false, 3 | "git.ignoreLimitWarning": true, 4 | "prettier.semi": false, 5 | "prettier.singleQuote": true, 6 | "prettier.trailingComma": "none", 7 | "editor.formatOnSave": true 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cypress-hyperapp-unit-test 2 | 3 | > Unit test [Hyperapp](https://hyperapp.js.org/) components using [Cypress](https://www.cypress.io/) 4 | 5 | [![NPM][npm-icon] ][npm-url] 6 | 7 | [![Build status][ci-image] ][ci-url] 8 | [![semantic-release][semantic-image] ][semantic-url] 9 | [![js-standard-style][standard-image]][standard-url] 10 | [![Cypress.io dashboard](https://img.shields.io/badge/cypress.io-tests-green.svg?style=flat-square)][cypress dashboard url] 11 | [![renovate-app badge][renovate-badge]][renovate-app] 12 | 13 | [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg 14 | [renovate-app]: https://renovateapp.com/ 15 | 16 | ## TLDR 17 | 18 | * What is this? This package allows you to use [Cypress](https://www.cypress.io/) test runner to unit test your Hyperapp components with zero effort. The component runs in the real browser with full power of Cypress E2E test runner: [live GUI, powerful API, screen recording, historical DOM snapshots, CI support, cross-platform](https://www.cypress.io/features/). 19 | 20 | * The line between unit testing a component that renders into a DOM, makes HTTP requests, uses browser API and an end-to-end test for a complete web application is becoming very blurry in my opinion. Hope this little bridge between Hyperapp and Cypress test runner proves it. See examples below - some of them are testing individual components, some full apps. But the unit and end-to-end tests look and run _very much alike_. 21 | 22 | ## Install 23 | 24 | Requires [Node](https://nodejs.org/en/) version 6 or above. 25 | 26 | ```sh 27 | npm install --save-dev cypress-hyperapp-unit-test 28 | ``` 29 | 30 | also requires peer dependencies in your project 31 | 32 | ```sh 33 | npm install cypress hyperapp 34 | ``` 35 | 36 | ## API 37 | 38 | You can import this module from your own tests 39 | 40 | ```js 41 | import { mount } from 'cypress-hyperapp-unit-test' 42 | // import or code state, action and view 43 | beforeEach(() => { 44 | mount(state, actions, view) 45 | }) 46 | // you get fresh mini-app running in each test 47 | ``` 48 | 49 | ## Use 50 | 51 | In your Cypress spec files (the example below is from file [cypress/integration/hello-world-spec.js](cypress/integration/hello-world-spec.js)) mount the application, just like you would "normally". 52 | 53 | ```js 54 | import { mount } from 'cypress-hyperapp-unit-test' 55 | import { h } from 'hyperapp' 56 | // view function we are testing 57 | const view = (state, actions) => h('div', { class: 'greeting' }, 'Hello, World') 58 | describe('Hello World', () => { 59 | beforeEach(() => { 60 | const state = {} 61 | const actions = {} 62 | // no state or actions for this simple example 63 | mount(state, actions, view) 64 | }) 65 | it('shows greeting', () => { 66 | // use any Cypress command - we have 67 | // real Hyperapp application for testing 68 | cy.contains('.greeting', 'Hello, World') 69 | }) 70 | }) 71 | ``` 72 | 73 | Start Cypress using `$(npm bin)/cypress open` and execute the spec. You have full end-to-end test run but with your component! Why waste time on unit testing inside synthetic DOM's blackbox if you could _see_ the result, _inspect_ the DOM, _investigate_ how it works using time-travelling debugger? 74 | 75 | ![Hello World shows greeting](images/hello-world.png) 76 | 77 | ## Examples 78 | 79 | * [simple view function without any actions](cypress/integration/hello-world-spec.js) 80 | * [components without and with actions](cypress/integration/hello-world-component-spec.js) 81 | * [single TodoItem component](cypress/integration/todo-item-spec.js) 82 | * [entire TodoList component](cypress/integration/todo-list-spec.js) 83 | * [server XHR stubbing](cypress/integration/server-todos-spec.js) 84 | * [TodoMVC application E2E test](cypress/integration/todo-app-e2e.js) for [apps/todo.html](apps/todo.html) 85 | 86 | Unit tests and E2E tests start looking very much alike. Compare [TodoList unit test](cypress/integration/todo-list-spec.js) and [TodoMVC end-to-end test](cypress/integration/todo-app-e2e.js). 87 | 88 | * Components and tests for Hyperapp using JSX are their own repository [bahmutov/hyperapp-counter-jsx-example](https://github.com/bahmutov/hyperapp-counter-jsx-example) to keep this repo simple. 89 | 90 | ## Repo organization 91 | 92 | * [src/index.js](src/index.js) the main file implementing `mount` 93 | * [components](components) different Hyper components for testing 94 | * [actions](actions) pure actions functions used from components and tests 95 | * [apps](apps) one or more complete bundled applications (build them using `npm run build`) 96 | * [cypress/integration](cypress/integration) example spec files showing various test situations 97 | 98 | See video of tests running on CI on the project's [Cypress Dashboard][cypress dashboard url] 99 | 100 | ## API Extras 101 | 102 | * Mounted component's actions object is attached to the global `Cypress.main` variable. The name `main` was picked because that's what Hyperapp uses in its docs `const main = app(state, ...)` 103 | * The `mount` function adds an action `_getState` to the `actions` object, if there is not one already present. This allows you to get the current state of the component for inspection. 104 | 105 | ```js 106 | Cypress.main.setName('Joe') 107 | Cypress.main 108 | ._getState() 109 | .its('name') 110 | .should('equal', 'Joe') 111 | Cypress.main.setAge(37) 112 | Cypress.main._getState().should('deep.equal', { 113 | name: 'Joe', 114 | age: 37 115 | }) 116 | ``` 117 | 118 | Note: the `Cypress.main` wraps returned Hyperapp actions with `cy.then` to queue the calls through the Cypress command queue. Thus the above code looks synchronous, but in reality there could be DOM updates, network calls, etc, and it still works. 119 | 120 | ## Package scripts 121 | 122 | * `npm run build` bundles complete applications if you want to run tests against full applications 123 | * `npm run cy:open` starts Cypress GUI, which is great for TDD mode 124 | * `npm run cy:run` runs Cypress headlessly, testing all specs. Same command [runs on CI](.travis.yml) with additional `--record` argument to record the run and send to the [Cypress Dashboard][cypress dashboard url] 125 | 126 | ## Similar adaptors 127 | 128 | * [cypress-vue-unit-test](https://github.com/bahmutov/cypress-vue-unit-test) 129 | * [cypress-react-unit-test](https://github.com/bahmutov/cypress-react-unit-test) 130 | * [cypress-cycle-unit-test](https://github.com/bahmutov/cypress-cycle-unit-test) 131 | * [cypress-svelte-unit-test](https://github.com/bahmutov/cypress-svelte-unit-test) 132 | * [cypress-angular-unit-test](https://github.com/bahmutov/cypress-angular-unit-test) 133 | * [cypress-hyperapp-unit-test](https://github.com/bahmutov/cypress-hyperapp-unit-test) 134 | * [cypress-angularjs-unit-test](https://github.com/bahmutov/cypress-angularjs-unit-test) 135 | 136 | ### Small print 137 | 138 | Author: Gleb Bahmutov <gleb.bahmutov@gmail.com> © 2017 139 | 140 | * [@bahmutov](https://twitter.com/bahmutov) 141 | * [glebbahmutov.com](https://glebbahmutov.com) 142 | * [blog](https://glebbahmutov.com/blog) 143 | 144 | License: MIT - do anything with the code, but don't blame me if it does not work. 145 | 146 | Support: if you find any problems with this module, email / tweet / 147 | [open issue](https://github.com/bahmutov/cypress-hyperapp-unit-test/issues) on Github 148 | 149 | ## MIT License 150 | 151 | Copyright (c) 2017 Gleb Bahmutov <gleb.bahmutov@gmail.com> 152 | 153 | Permission is hereby granted, free of charge, to any person 154 | obtaining a copy of this software and associated documentation 155 | files (the "Software"), to deal in the Software without 156 | restriction, including without limitation the rights to use, 157 | copy, modify, merge, publish, distribute, sublicense, and/or sell 158 | copies of the Software, and to permit persons to whom the 159 | Software is furnished to do so, subject to the following 160 | conditions: 161 | 162 | The above copyright notice and this permission notice shall be 163 | included in all copies or substantial portions of the Software. 164 | 165 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 166 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 167 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 168 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 169 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 170 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 171 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 172 | OTHER DEALINGS IN THE SOFTWARE. 173 | 174 | [npm-icon]: https://nodei.co/npm/cypress-hyperapp-unit-test.svg?downloads=true 175 | [npm-url]: https://npmjs.org/package/cypress-hyperapp-unit-test 176 | [ci-image]: https://travis-ci.org/bahmutov/cypress-hyperapp-unit-test.svg?branch=master 177 | [ci-url]: https://travis-ci.org/bahmutov/cypress-hyperapp-unit-test 178 | [semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg 179 | [semantic-url]: https://github.com/semantic-release/semantic-release 180 | [standard-image]: https://img.shields.io/badge/code%20style-standard-brightgreen.svg 181 | [standard-url]: http://standardjs.com/ 182 | [cypress dashboard url]: https://dashboard.cypress.io/#/projects/zsoa27 183 | -------------------------------------------------------------------------------- /actions/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | // common pure actions for different components AND tests 4 | 5 | export const toggle = ({ id, completed }) => state => { 6 | // poor man's find and toggle todo, can be really shortened 7 | // with Ramda or other functional libraries. 8 | 9 | // Luckily such pure functions are 10 | // SUPER SIMPLE to test and refactor 11 | // as much as you want 12 | return { 13 | todos: state.todos.map( 14 | t => (t.id === id ? Object.assign({}, t, { completed }) : t) 15 | ) 16 | } 17 | } 18 | 19 | export const getTodos = (n = 5, loaded) => { 20 | const url = `http://jsonplaceholder.typicode.com/todos?_limit=${n}` 21 | return axios.get(url).then(res => res.data).then(list => { 22 | console.log('got %d todos', list.length) 23 | if (loaded) { 24 | loaded(list) 25 | } 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /apps/todo-app.js: -------------------------------------------------------------------------------- 1 | import { h, app } from 'hyperapp' 2 | import { TodoList } from '../components/todo-list' 3 | import { toggle } from '../actions' 4 | 5 | // const { h, app } = window.hyperapp 6 | 7 | // notice how much this application looks like TodoList "unit" test 8 | // in cypress/integration/todo-list-spec.js 9 | 10 | console.log('starting todo app') 11 | const state = { 12 | todos: [ 13 | { 14 | id: 1, 15 | title: 'Write HyperApp', 16 | completed: false 17 | }, 18 | { 19 | id: 2, 20 | title: 'Test it using Cypress', 21 | completed: false 22 | } 23 | ] 24 | } 25 | const actions = { toggle } 26 | const view = (state, actions) => 27 | // renders TodoList component, passing 28 | // current state and actions 29 | h(TodoList, { 30 | todos: state.todos, 31 | toggle: actions.toggle 32 | }) 33 | 34 | app(state, actions, view, document.getElementById('app')) 35 | -------------------------------------------------------------------------------- /apps/todo.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /components/hello-world.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | 3 | // hard-coded greeting message 4 | export const HelloWorld = (state, actions) => 5 | h('div', { class: 'greeting' }, 'Hello, World') 6 | 7 | // dynamic greeting message 8 | export const HelloYou = (state, actions) => 9 | h('div', { class: 'greeting' }, `Hello, ${state.name}`) 10 | -------------------------------------------------------------------------------- /components/pico-world.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | import picostyle from 'picostyle' 3 | 4 | const style = picostyle(h) 5 | const theme = 'hotpink' // Try change the theme to white 6 | 7 | const keyColor = '#f07' 8 | 9 | export const Wrapper = style('div')({ 10 | display: 'flex', 11 | justifyContent: 'center', 12 | alignItems: 'center', 13 | width: '100vw', 14 | height: '100vh', 15 | backgroundColor: keyColor 16 | }) 17 | 18 | export const Text = style('h1')({ 19 | fontSize: 'calc(10px + 5vmin)', 20 | color: theme === 'white' ? 'black' : 'white', 21 | margin: 'auto', 22 | transition: 'transform .2s ease-out', 23 | ':hover': { 24 | transform: 'scale(1.2)' 25 | }, 26 | '@media (orientation: landscape)': { 27 | fontWeight: 'bold' 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /components/server-todos.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | import { TodoList } from './todo-list' 3 | import { getTodos } from '../actions' 4 | 5 | // Todo list that fetches itself from the remote server 6 | // https://github.com/hyperapp/hyperapp/blob/master/docs/concepts/lifecycle-events.md 7 | // http://jsonplaceholder.typicode.com/ 8 | 9 | // n - how many todos to fetch from the server 10 | export const ServerTodos = ({ n, todosLoaded, todos, toggle }) => { 11 | const loadTodos = () => getTodos(n, todosLoaded) 12 | 13 | return h( 14 | 'div', 15 | { 16 | oncreate: loadTodos 17 | }, 18 | [ 19 | h(TodoList, { 20 | todos, 21 | toggle 22 | }) 23 | ] 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /components/todo-item.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | 3 | // TodoItem component from 4 | // https://github.com/hyperapp/hyperapp/blob/master/docs/concepts/components.md 5 | export const TodoItem = ({ id, title, completed, toggle }) => { 6 | completed = Boolean(completed) 7 | 8 | const onclick = e => 9 | toggle({ 10 | completed: !completed, 11 | id 12 | }) 13 | 14 | const className = `todo ${completed ? 'done' : ''}` 15 | 16 | return h( 17 | 'li', 18 | { class: className, onclick }, 19 | h('div', { class: 'view' }, [ 20 | h('input', { 21 | class: 'toggle', 22 | type: 'checkbox', 23 | checked: completed 24 | }), 25 | h('label', null, title) 26 | ]) 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/todo-list.js: -------------------------------------------------------------------------------- 1 | import { h } from 'hyperapp' 2 | import { TodoItem } from './todo-item' 3 | 4 | // TodoList component from 5 | // https://github.com/hyperapp/hyperapp/blob/master/docs/concepts/components.md 6 | export const TodoList = ({ todos, toggle }) => 7 | h('div', {}, [ 8 | h('h1', {}, 'Todo'), 9 | h( 10 | 'ul', 11 | { class: 'todo-list' }, 12 | // for each todo object, call TodoItem view function, 13 | // combining todo with toggle action and passing as properties object 14 | todos.map(todo => h(TodoItem, Object.assign({}, todo, { toggle }))) 15 | ) 16 | ]) 17 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "viewportHeight": 200, 3 | "viewportWidth": 300, 4 | "video": false, 5 | "projectId": "zsoa27", 6 | "defaultCommandTimeout": 4000 7 | } 8 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/edge-spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { h } from 'hyperapp' 4 | import { mount } from '../../src' 5 | 6 | const view = (/* state, actions */) => 7 | h('div', { class: 'greeting' }, 'edge cases') 8 | 9 | /* eslint-env mocha */ 10 | describe('Edge cases', () => { 11 | it('works without state', () => { 12 | mount(null, {}, view) 13 | cy.contains('.greeting', 'edge cases').then(() => { 14 | Cypress.main._getState().should('be', null) 15 | }) 16 | }) 17 | 18 | it('works without actions', () => { 19 | mount(null, null, view) 20 | cy.contains('.greeting', 'edge cases').then(() => { 21 | Cypress.main._getState().should('be', null) 22 | }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /cypress/integration/hello-world-component-spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { HelloWorld, HelloYou } from '../../components/hello-world' 4 | import { mount } from '../../src' 5 | 6 | /* eslint-env mocha */ 7 | describe('HelloWorld', () => { 8 | // component without any actions or internal state 9 | beforeEach(() => { 10 | const state = {} 11 | const actions = {} 12 | mount(state, actions, HelloWorld) 13 | }) 14 | 15 | it('shows greeting', () => { 16 | cy.contains('.greeting', 'Hello, World') 17 | }) 18 | }) 19 | 20 | describe('HelloYou', () => { 21 | // component with state and an action 22 | const state = { 23 | name: 'person' 24 | } 25 | 26 | beforeEach(() => { 27 | const actions = { 28 | setName: name => state => ({ name }) 29 | } 30 | mount(state, actions, HelloYou) 31 | }) 32 | 33 | it('shows greeting', () => { 34 | cy.contains('.greeting', 'Hello, person') 35 | }) 36 | 37 | it('changes greeting using action', () => { 38 | // Cypress.main is the mounted value which is 39 | // the app's actions object 40 | cy.log('changing name') 41 | Cypress.main.setName('Great Person!') 42 | cy.contains('.greeting', 'Hello, Great Person!') 43 | }) 44 | 45 | it('mutates name in the state', () => { 46 | Cypress.main.setName('Great Person!') 47 | // mount function adds utility method to get the 48 | // current state object 49 | Cypress.main._getState().its('name').should('equal', 'Great Person!') 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /cypress/integration/hello-world-spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { h } from 'hyperapp' 4 | import { mount } from '../../src' 5 | 6 | // view function we are testing - generates "static" content 7 | // so we don't even need state and actions arguments 8 | const view = (/* state, actions */) => 9 | h('div', { class: 'greeting' }, 'Hello, World') 10 | 11 | /* eslint-env mocha */ 12 | describe('Hello World', () => { 13 | // simplest case - no state, no actions 14 | // just a view function 15 | beforeEach(() => { 16 | const state = {} 17 | const actions = {} 18 | mount(state, actions, view) 19 | }) 20 | 21 | it('shows greeting', () => { 22 | cy.contains('.greeting', 'Hello, World') 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /cypress/integration/pico-world-spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { h } from 'hyperapp' 4 | import { mount } from '../../src' 5 | import { Wrapper, Text } from '../../components/pico-world' 6 | 7 | /* eslint-env mocha */ 8 | describe('Picostyle Text', () => { 9 | beforeEach(() => { 10 | cy.window().its('document').then(doc => { 11 | console.log('doc', doc) 12 | }) 13 | const view = state => h(Wrapper, {}, [h(Text, {}, [`Hello ${state.text}`])]) 14 | 15 | mount( 16 | { 17 | text: 'Picostyle' 18 | }, 19 | {}, 20 | view 21 | ) 22 | }) 23 | 24 | it.skip('shows greeting', () => { 25 | cy.contains('Hello Picostyle') 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /cypress/integration/server-todos-spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { h } from 'hyperapp' 4 | import { ServerTodos } from '../../components/server-todos' 5 | import { mount } from '../../src' 6 | import { toggle } from '../../actions' 7 | 8 | /* eslint-env mocha */ 9 | describe('Server Todos', () => { 10 | const mockTodos = [ 11 | { 12 | id: 1, 13 | title: 'Stub server', 14 | completed: false 15 | }, 16 | { 17 | id: 2, 18 | title: 'Test app', 19 | completed: false 20 | }, 21 | { 22 | id: 3, 23 | title: 'Profit!', 24 | completed: true 25 | } 26 | ] 27 | beforeEach(() => { 28 | // expect XHR from the component 29 | // and respond with mock list 30 | cy.server() 31 | cy.route('/todos?_limit=3', mockTodos).as('todos') 32 | }) 33 | 34 | context('Stubbed server', () => { 35 | beforeEach(() => { 36 | const state = { 37 | todos: [] 38 | } 39 | const actions = { 40 | setTodos: todos => state => ({ todos }), 41 | toggle 42 | } 43 | const view = (state, actions) => 44 | h(ServerTodos, { 45 | n: 3, 46 | todosLoaded: actions.setTodos, 47 | todos: state.todos, 48 | toggle: actions.toggle 49 | }) 50 | mount(state, actions, view) 51 | }) 52 | 53 | it('shows todos', () => { 54 | cy.contains('Todo') 55 | cy.get('.todo').should('have.length', 3) 56 | cy.get('.todo').first().contains('Stub server') 57 | }) 58 | 59 | it('stubs XHR response', () => { 60 | cy.wait('@todos').its('response.body').should('deep.equal', mockTodos) 61 | }) 62 | 63 | it('can toggle item', () => { 64 | cy.get('.todo').first().click() 65 | cy.get('.todo').first().find('.toggle').should('be.checked') 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /cypress/integration/todo-app-e2e.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /* eslint-env mocha */ 4 | describe('Todo App E2E', () => { 5 | beforeEach(() => { 6 | // optional stubbing of Todos from a server 7 | // can be done like this 8 | // cy.server() 9 | // cy 10 | // .route('/todos?_limit=3', [ 11 | // { 12 | // id: 100, 13 | // completed: true, 14 | // title: 'stub server' 15 | // } 16 | // ]) 17 | // .as('todos') 18 | 19 | // note this is end-to-end test for entire applicsation 20 | // instead of loading individusl components 21 | // we are just visiting a page - we assume the 22 | // application has been bundled, loaded and initialized 23 | cy.visit('bundles/todo/todo.html') 24 | }) 25 | 26 | it('loads Todo app', () => { 27 | cy.contains('h1', 'Todo') 28 | }) 29 | 30 | it('shows 2 todos', () => { 31 | cy.get('.todo').should('have.length', 2) 32 | }) 33 | 34 | it('toggles second todo several times', () => { 35 | cy 36 | .get('.todo') 37 | .eq(1) 38 | .click() 39 | .click() 40 | .click() 41 | .find('.toggle') 42 | .should('be.checked') 43 | }) 44 | 45 | it('completes first todo', () => { 46 | cy.get('.todo').first().click().find('.toggle').should('be.checked') 47 | // second item is still not checked 48 | cy.get('.todo').eq(1).find('.toggle').should('not.be.checked') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /cypress/integration/todo-item-spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { h } from 'hyperapp' 4 | import { TodoItem } from '../../components/todo-item' 5 | import { mount } from '../../src' 6 | 7 | /* eslint-env mocha */ 8 | describe('TodoItem', () => { 9 | beforeEach(() => { 10 | const state = { 11 | title: 'Try HyperApp', 12 | completed: false 13 | } 14 | const actions = { 15 | toggle: () => state => ({ completed: !state.completed }) 16 | } 17 | const view = (state, actions) => 18 | h(TodoItem, { 19 | id: 1, 20 | title: state.title, 21 | completed: state.completed, 22 | toggle: actions.toggle 23 | }) 24 | mount(state, actions, view) 25 | }) 26 | 27 | it('shows todo', () => { 28 | cy.contains('Try HyperApp') 29 | }) 30 | 31 | it('is unchecked for unfinished item', () => { 32 | cy.get('.toggle').should('have.length', 1).should('not.be.checked') 33 | }) 34 | 35 | it('toggles item on click', () => { 36 | cy.get('.todo').click() 37 | cy.get('.toggle').should('be.checked') 38 | }) 39 | 40 | it('changes state on click', () => { 41 | cy.get('.todo').click() 42 | Cypress.main._getState().should('deep.equal', { 43 | completed: true, 44 | title: 'Try HyperApp' 45 | }) 46 | 47 | // click again 48 | cy.get('.todo').click() 49 | Cypress.main._getState().should('deep.equal', { 50 | completed: false, 51 | title: 'Try HyperApp' 52 | }) 53 | }) 54 | 55 | it('changes state by invoking action', () => { 56 | // the component's actions are referenced in Cypress.main 57 | Cypress.main.toggle() 58 | cy.get('.toggle').should('be.checked') 59 | // because actions inside Cypress.main are queued into the 60 | // Cypress command queue first we can just call them 61 | // the toggle below will happen AFTER the toggle check above 62 | // has already passed 63 | Cypress.main.toggle() 64 | cy.get('.toggle').should('not.be.checked') 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /cypress/integration/todo-list-spec.js: -------------------------------------------------------------------------------- 1 | /// 2 | import { h } from 'hyperapp' 3 | import { TodoList } from '../../components/todo-list' 4 | import { toggle } from '../../actions' 5 | import { mount } from '../../src' 6 | 7 | /* eslint-env mocha */ 8 | describe('TodoList', () => { 9 | // let's test TodoList component by giving it simple 10 | // state and a toggle action 11 | 12 | // initial state 13 | const state = { 14 | todos: [ 15 | { 16 | id: 1, 17 | title: 'Try HyperApp', 18 | completed: true 19 | }, 20 | { 21 | id: 2, 22 | title: 'Test using Cypress', 23 | completed: true 24 | }, 25 | { 26 | id: 3, 27 | title: 'Profit!!!', 28 | completed: false 29 | } 30 | ] 31 | } 32 | // reuse toggle action in the test - why not? 33 | const actions = { 34 | toggle 35 | } 36 | const view = (state, actions) => 37 | // renders TodoList component, passing 38 | // current state and actions 39 | h(TodoList, { 40 | todos: state.todos, 41 | toggle: actions.toggle 42 | }) 43 | 44 | beforeEach(() => { 45 | mount(state, actions, view) 46 | }) 47 | 48 | it('shows todo', () => { 49 | cy.contains('Todo') 50 | }) 51 | 52 | it('has expected number of todos', () => { 53 | cy.get('.todo-list .todo').should('have.length', 3) 54 | }) 55 | 56 | it('has 2 completed todos', () => { 57 | cy.get('.todo-list .todo.done').should('have.length', 2) 58 | }) 59 | 60 | it('is done with testing', () => { 61 | cy 62 | .contains('.todo-list .todo', 'Test using Cypress') 63 | .find('.toggle') 64 | .should('be.checked') 65 | }) 66 | 67 | it('does not have profit yet', () => { 68 | cy 69 | .contains('.todo-list .todo', 'Profit') 70 | .find('.toggle') 71 | .should('not.be.checked') 72 | }) 73 | 74 | it('completes todo by clicking', () => { 75 | cy.contains('.todo-list .todo', 'Profit').click() 76 | cy 77 | .contains('.todo-list .todo', 'Profit') 78 | .find('.toggle') 79 | .should('be.checked') 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /images/hello-world.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bahmutov/cypress-hyperapp-unit-test/ffe91bb180820ac99fe820f45696c331659faa78/images/hello-world.png -------------------------------------------------------------------------------- /issue_template.md: -------------------------------------------------------------------------------- 1 | Thank you for taking time to open a new issue. Please answer a few questions to help us fix it faster. You can delete text that is irrelevant to the issue. 2 | 3 | ## Is this a bug report or a feature request? 4 | 5 | If this is a bug report, please provide as much info as possible 6 | 7 | - version 8 | - platform 9 | - expected behavior 10 | - actual behavior 11 | 12 | If this is a new feature request, please describe it below 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cypress-hyperapp-unit-test", 3 | "description": "Unit test Hyperapp components using Cypress", 4 | "version": "0.0.0-development", 5 | "author": "Gleb Bahmutov ", 6 | "bugs": "https://github.com/bahmutov/cypress-hyperapp-unit-test/issues", 7 | "config": { 8 | "pre-git": { 9 | "commit-msg": "simple", 10 | "pre-commit": [ 11 | "npm test", 12 | "git add src/*.js", 13 | "npm run ban" 14 | ], 15 | "pre-push": [ 16 | "npm run secure", 17 | "npm run license", 18 | "npm run ban -- --all", 19 | "npm run size" 20 | ], 21 | "post-commit": [], 22 | "post-merge": [] 23 | } 24 | }, 25 | "engines": { 26 | "node": ">=6" 27 | }, 28 | "files": [ 29 | "src/*.js", 30 | "!src/*-spec.js", 31 | "dist" 32 | ], 33 | "homepage": "https://github.com/bahmutov/cypress-hyperapp-unit-test#readme", 34 | "keywords": [ 35 | "cypress", 36 | "cypress-io", 37 | "hyperapp", 38 | "test" 39 | ], 40 | "license": "MIT", 41 | "main": "dist", 42 | "module": "src", 43 | "private": false, 44 | "publishConfig": { 45 | "registry": "http://registry.npmjs.org/" 46 | }, 47 | "repository": { 48 | "type": "git", 49 | "url": "https://github.com/bahmutov/cypress-hyperapp-unit-test.git" 50 | }, 51 | "scripts": { 52 | "ban": "ban", 53 | "deps": "deps-ok && dependency-check --no-dev .", 54 | "issues": "git-issues", 55 | "license": "license-checker --production --onlyunknown --csv", 56 | "lint": "standard --verbose --fix src/*.js", 57 | "prelint": "npm run pretty", 58 | "pretest": "npm run lint", 59 | "pretty": "prettier-standard 'src/*.js'", 60 | "secure": "nsp check", 61 | "size": "t=\"$(npm pack .)\"; wc -c \"${t}\"; tar tvf \"${t}\"; rm \"${t}\";", 62 | "test": "cypress run", 63 | "pretest:ci": "npm run build", 64 | "test:ci": "npm run test", 65 | "test:ci:record": "npm run cy:run:record", 66 | "cy:open": "cypress open", 67 | "cy:run": "cypress run", 68 | "cy:run:record": "cypress run --record", 69 | "unit": "mocha src/*-spec.js", 70 | "unused-deps": "dependency-check --unused --no-dev .", 71 | "semantic-release": "semantic-release", 72 | "transpile": "tsc", 73 | "prebuild": "npm run transpile", 74 | "build": "npm run build:todo-app", 75 | "build:todo-app": "parcel build apps/todo.html -d bundles/todo --public-url ./" 76 | }, 77 | "release": { 78 | "analyzeCommits": "simple-commit-message" 79 | }, 80 | "devDependencies": { 81 | "@types/node": "9.6.61", 82 | "axios": "0.21.1", 83 | "ban-sensitive-files": "1.9.15", 84 | "common-tags": "1.8.0", 85 | "cypress": "3.8.3", 86 | "dependency-check": "3.4.1", 87 | "deps-ok": "1.4.1", 88 | "git-issues": "1.3.1", 89 | "hyperapp": "1.2.10", 90 | "license-checker": "20.2.0", 91 | "mocha": "5.2.0", 92 | "nsp": "3.2.1", 93 | "parcel-bundler": "1.12.4", 94 | "picostyle": "2.2.0", 95 | "pre-git": "3.17.1", 96 | "prettier-standard": "8.0.1", 97 | "simple-commit-message": "4.1.2", 98 | "standard": "12.0.1", 99 | "typescript": "3.9.9", 100 | "semantic-release": "15.14.0" 101 | }, 102 | "standard": { 103 | "globals": [ 104 | "Cypress", 105 | "cy", 106 | "expect" 107 | ] 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true, 4 | "major": { 5 | "automerge": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { stripIndent } from 'common-tags' 2 | 3 | // having weak reference to styles prevents garbage collection 4 | // and "losing" styles when the next test starts 5 | const stylesCache = new Map() 6 | 7 | const copyStyles = component => { 8 | // need to find same component when component is recompiled 9 | // by the JSX preprocessor. Thus have to use something else, 10 | // like component name 11 | // const hash = component.type.name 12 | const hash = component 13 | 14 | let styles = document.querySelectorAll('head style') 15 | if (styles.length) { 16 | console.log('injected %d styles', styles.length) 17 | stylesCache.set(hash, styles) 18 | } else { 19 | console.log('No styles injected for this component, checking cache') 20 | if (stylesCache.has(hash)) { 21 | styles = stylesCache.get(hash) 22 | } else { 23 | styles = null 24 | } 25 | } 26 | 27 | if (!styles) { 28 | return 29 | } 30 | 31 | const parentDocument = window.parent.document 32 | const projectName = Cypress.config('projectName') 33 | const appIframeId = `Your App: '${projectName}'` 34 | const appIframe = parentDocument.getElementById(appIframeId) 35 | const head = appIframe.contentDocument.querySelector('head') 36 | styles.forEach(style => { 37 | head.appendChild(style) 38 | }) 39 | } 40 | 41 | function setXMLHttpRequest (w) { 42 | // by grabbing the XMLHttpRequest from app's iframe 43 | // and putting it here - in the test iframe 44 | // we suddenly get spying and stubbing 😁 45 | window.XMLHttpRequest = w.XMLHttpRequest 46 | return w 47 | } 48 | 49 | function setAlert (w) { 50 | window.alert = w.alert 51 | return w 52 | } 53 | 54 | export const mount = (state, actions, view) => { 55 | if (!actions) { 56 | // we always want to have an actions object so we 57 | // can attach _getState utility function 58 | actions = {} 59 | } 60 | 61 | // TODO stop hard coding Hyperapp version, grab from the "node_modules" 62 | const html = stripIndent` 63 | 64 | 65 | 66 | 67 |
68 | 69 | 70 | ` 71 | const document = cy.state('document') 72 | document.write(html) 73 | document.close() 74 | 75 | // add a utility action to get the current state 76 | if (!actions._getState) { 77 | const _getState = () => state => state 78 | actions = Object.assign({}, actions, { _getState }) 79 | } 80 | 81 | // force waiting for window.hyperapp to exist before anything else happens 82 | cy 83 | .window({ log: false }) 84 | .its('hyperapp') 85 | .should('be.an', 'object') 86 | 87 | // now can attach our handlers 88 | cy 89 | .window({ log: false }) 90 | .then(setXMLHttpRequest) 91 | .then(setAlert) 92 | .its('hyperapp.app') 93 | .then(app => { 94 | const el = document.getElementById('app') 95 | const main = app(state, actions, view, el) 96 | 97 | // wrap every hyper action with `cy.then` to 98 | // make sure it goes through the Cypress command queue 99 | // allows things like the example below to just work 100 | // Cypress.main.setText('foo') 101 | // cy.contains('foo') 102 | // Cypress.main.setText('bar') 103 | // cy.contains('bar') 104 | Cypress.main = {} 105 | Object.keys(main).forEach(name => { 106 | const action = main[name] 107 | Cypress.main[name] = function queueAction () { 108 | // should we log arguments? 109 | cy.log(`action: ${name}`) 110 | return cy.then(() => action.apply(null, arguments)) 111 | } 112 | }) 113 | 114 | return Cypress.main 115 | }) 116 | 117 | cy.get('#app', { log: false }).should('be.visible') 118 | 119 | // picostyle dom is more complex 120 | // picostyle inserts a style into the document's head 121 | // but then it actually inserts new CSS rules when the view instantiates 122 | // so we need to _observe_ test document style and on added 123 | // CSS rules copy them 124 | copyStyles(view) 125 | } 126 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation: */ 7 | "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 12 | // "outFile": "./", /* Concatenate and emit output to single file. */ 13 | "outDir": "dist", /* Redirect output structure to the directory. */ 14 | // "rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 15 | // "removeComments": true, /* Do not emit comments to output. */ 16 | // "noEmit": true, /* Do not emit outputs. */ 17 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 18 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 19 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 20 | 21 | /* Strict Type-Checking Options */ 22 | "strict": false /* Enable all strict type-checking options. */ 23 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 24 | // "strictNullChecks": true, /* Enable strict null checks. */ 25 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 26 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 27 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 28 | 29 | /* Additional Checks */ 30 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 31 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 32 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 33 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 34 | 35 | /* Module Resolution Options */ 36 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 37 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 38 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 39 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 40 | // "typeRoots": [], /* List of folders to include type definitions from. */ 41 | // "types": [], /* Type declaration files to be included in compilation. */ 42 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 43 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 44 | 45 | /* Source Map Options */ 46 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 47 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 48 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 49 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 50 | 51 | /* Experimental Options */ 52 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 53 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 54 | }, 55 | "include": [ 56 | "src/index.js" 57 | ] 58 | } 59 | --------------------------------------------------------------------------------