From ed72e2d7db04fc9a0d4367495e021d21725d18bf Mon Sep 17 00:00:00 2001 From: Nico Jansen Date: Thu, 29 Nov 2018 12:16:05 +0100 Subject: [PATCH] test(Jest integration test): Improve quality of jest integration test (#1260) Improve quality of jest integration test. Before now, we only had 1 test without assertions running as integration. It is better than nothing, but far from ideal. I now grabbed some tests from reactstrap. I think this will help build confidence when improving the jest runner. --- integrationTest/test/jest-react/.babelrc | 20 ++ integrationTest/test/jest-react/.gitignore | 26 +- integrationTest/test/jest-react/README.md | 3 + .../test/jest-react/__mocks__/react-popper.js | 17 + integrationTest/test/jest-react/package.json | 87 ++++- integrationTest/test/jest-react/src/Alert.js | 86 +++++ integrationTest/test/jest-react/src/App.css | 28 -- integrationTest/test/jest-react/src/App.js | 19 -- .../test/jest-react/src/App.test.js | 8 - integrationTest/test/jest-react/src/Badge.js | 52 +++ .../test/jest-react/src/Breadcrumb.js | 55 ++++ .../test/jest-react/src/BreadcrumbItem.js | 39 +++ integrationTest/test/jest-react/src/Fade.js | 85 +++++ .../test/jest-react/src/__tests__/.eslintrc | 18 ++ .../jest-react/src/__tests__/Alert.spec.js | 101 ++++++ .../jest-react/src/__tests__/Badge.spec.js | 47 +++ .../src/__tests__/Breadcrumb.spec.js | 35 ++ .../jest-react/src/__tests__/utils.spec.js | 221 +++++++++++++ integrationTest/test/jest-react/src/index.css | 5 - integrationTest/test/jest-react/src/index.js | 21 +- .../test/jest-react/src/setupTests.js | 27 ++ integrationTest/test/jest-react/src/utils.js | 301 ++++++++++++++++++ .../test/jest-react/stryker.conf.js | 5 +- .../test/jest-react/verify/verify.ts | 41 ++- .../test/typescript-transpiling/stryker.log | 0 25 files changed, 1236 insertions(+), 111 deletions(-) create mode 100644 integrationTest/test/jest-react/.babelrc create mode 100644 integrationTest/test/jest-react/README.md create mode 100644 integrationTest/test/jest-react/__mocks__/react-popper.js create mode 100644 integrationTest/test/jest-react/src/Alert.js delete mode 100644 integrationTest/test/jest-react/src/App.css delete mode 100644 integrationTest/test/jest-react/src/App.js delete mode 100644 integrationTest/test/jest-react/src/App.test.js create mode 100644 integrationTest/test/jest-react/src/Badge.js create mode 100644 integrationTest/test/jest-react/src/Breadcrumb.js create mode 100644 integrationTest/test/jest-react/src/BreadcrumbItem.js create mode 100644 integrationTest/test/jest-react/src/Fade.js create mode 100644 integrationTest/test/jest-react/src/__tests__/.eslintrc create mode 100644 integrationTest/test/jest-react/src/__tests__/Alert.spec.js create mode 100644 integrationTest/test/jest-react/src/__tests__/Badge.spec.js create mode 100644 integrationTest/test/jest-react/src/__tests__/Breadcrumb.spec.js create mode 100644 integrationTest/test/jest-react/src/__tests__/utils.spec.js delete mode 100644 integrationTest/test/jest-react/src/index.css create mode 100644 integrationTest/test/jest-react/src/setupTests.js create mode 100644 integrationTest/test/jest-react/src/utils.js delete mode 100644 integrationTest/test/typescript-transpiling/stryker.log diff --git a/integrationTest/test/jest-react/.babelrc b/integrationTest/test/jest-react/.babelrc new file mode 100644 index 0000000000..3fed1fce7a --- /dev/null +++ b/integrationTest/test/jest-react/.babelrc @@ -0,0 +1,20 @@ +{ + "presets": [ + ["es2015", {"modules": false}], + "react" + ], + "plugins": [ + "transform-object-rest-spread" + ], + "env": { + "lib-dir": { + "plugins": ["transform-es2015-modules-commonjs"] + }, + "webpack": { + "plugins": ["transform-es2015-modules-commonjs"] + }, + "test": { + "plugins": ["transform-es2015-modules-commonjs"] + } + } +} diff --git a/integrationTest/test/jest-react/.gitignore b/integrationTest/test/jest-react/.gitignore index d30f40ef44..f06f3ddb5a 100644 --- a/integrationTest/test/jest-react/.gitignore +++ b/integrationTest/test/jest-react/.gitignore @@ -1,21 +1,9 @@ -# See https://help.github.com/ignore-files/ for more about ignoring files. - -# dependencies -/node_modules - -# testing /coverage - -# production +node_modules +/dist /build - -# misc -.DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local - -npm-debug.log* -yarn-debug.log* -yarn-error.log* +/lib +.idea +.history +npm-debug.log +package-lock.json diff --git a/integrationTest/test/jest-react/README.md b/integrationTest/test/jest-react/README.md new file mode 100644 index 0000000000..19c1128d13 --- /dev/null +++ b/integrationTest/test/jest-react/README.md @@ -0,0 +1,3 @@ +# Reactstrap sample + +Grabbed from: https://reactstrap.github.io diff --git a/integrationTest/test/jest-react/__mocks__/react-popper.js b/integrationTest/test/jest-react/__mocks__/react-popper.js new file mode 100644 index 0000000000..53728460c1 --- /dev/null +++ b/integrationTest/test/jest-react/__mocks__/react-popper.js @@ -0,0 +1,17 @@ +import React from 'react'; + +export function Manager({ tag: Tag = 'div', ...props }) { + return ; +} + +export function Popper({ component: Tag = 'div', ...props }) { + return ; +} + +export function Arrow({ component: Tag = 'div', ...props }) { + return ; +} + +export function Target({ component: Tag = 'div', ...props }) { + return ; +} diff --git a/integrationTest/test/jest-react/package.json b/integrationTest/test/jest-react/package.json index 72d1c5568a..4f5032ee1c 100644 --- a/integrationTest/test/jest-react/package.json +++ b/integrationTest/test/jest-react/package.json @@ -1,17 +1,88 @@ { - "name": "react-app", - "version": "0.1.0", - "private": true, + "name": "reactstrap-sample", + "version": "6.5.0", + "description": "React Bootstrap 4 components", + "main": "dist/reactstrap.cjs.js", + "jsnext:main": "dist/reactstrap.es.js", + "module": "dist/reactstrap.es.js", + "jsdelivr": "dist/reactstrap.min.js", + "unpkg": "dist/reactstrap.min.js", + "cdn": "dist/reactstrap.min.js", + "esnext": "src", + "sideEffects": false, "scripts": { - "postinstall": "install-local", + "report-coverage": "coveralls < ./coverage/lcov.info", + "test-original": "cross-env BABEL_ENV=test react-scripts test --env=jsdom", "test": "stryker run", - "posttest": "mocha --require ts-node/register verify/*.ts" + "posttest": "mocha --require ts-node/register verify/*.ts", + "cover": "npm test -- --coverage", + "start": "cross-env BABEL_ENV=webpack webpack-dev-server --config ./webpack.dev.config.js --watch", + "build-docs": "cross-env BABEL_ENV=webpack WEBPACK_BUILD=production webpack --config ./webpack.dev.config.js --progress --colors", + "build": "rollup -c", + "prebuild": "cross-env BABEL_ENV=lib-dir babel src --out-dir lib --ignore src/__tests__/", + "postinstall": "install-local", + "postbuild": "node ./scripts/postbuild.js", + "create-release": "npm run cover && sh ./scripts/release", + "publish-release": "npm run cover && sh ./scripts/publish", + "lint": "eslint src" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/reactstrap/reactstrap.git" + }, + "files": [ + "LICENSE", + "README.md", + "CHANGELOG.md", + "lib", + "dist", + "src" + ], + "keywords": [ + "reactstrap", + "bootstrap", + "react", + "component", + "components", + "react-component", + "ui" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/reactstrap/reactstrap/issues" + }, + "homepage": "https://github.com/reactstrap/reactstrap#readme", + "dependencies": { + "classnames": "^2.2.3", + "lodash.isfunction": "^3.0.9", + "lodash.isobject": "^3.0.2", + "lodash.tonumber": "^4.0.3", + "prop-types": "^15.5.8", + "react-lifecycles-compat": "^3.0.4", + "react-popper": "^0.10.4", + "react-transition-group": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.0.0", + "react-dom": "^16.0.0" }, "devDependencies": { - "react": "^16.2.0", - "react-dom": "^16.2.0", + "babel-plugin-transform-object-rest-spread": "^6.23.0", + "babel-preset-es2015-rollup": "^3.0.0", + "babel-preset-react": "^6.11.1", + "babel-preset-react-app": "^0.2.1", + "cross-env": "^2.0.0", + "jest": "^23.3.0", + "ejs": "^2.5.9", + "enzyme": "^3.3.0", + "enzyme-adapter-react-16": "^1.1.1", + "react": "^16.3.2", + "react-dom": "^16.3.2", + "react-helmet": "^5.0.3", + "react-prism": "^4.3.2", + "react-router": "^3.2.1", "react-scripts": "^1.1.4", - "jest": "^23.3.0" + "react-test-renderer": "^16.3.2" }, "localDependencies": { "stryker": "../../../packages/stryker", diff --git a/integrationTest/test/jest-react/src/Alert.js b/integrationTest/test/jest-react/src/Alert.js new file mode 100644 index 0000000000..714fb82785 --- /dev/null +++ b/integrationTest/test/jest-react/src/Alert.js @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules } from './utils'; +import Fade from './Fade'; + +const propTypes = { + children: PropTypes.node, + className: PropTypes.string, + closeClassName: PropTypes.string, + closeAriaLabel: PropTypes.string, + cssModule: PropTypes.object, + color: PropTypes.string, + fade: PropTypes.bool, + isOpen: PropTypes.bool, + toggle: PropTypes.func, + tag: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + transition: PropTypes.shape(Fade.propTypes), + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), +}; + +const defaultProps = { + color: 'success', + isOpen: true, + tag: 'div', + closeAriaLabel: 'Close', + fade: true, + transition: { + ...Fade.defaultProps, + unmountOnExit: true, + }, +}; + +function Alert(props) { + const { + className, + closeClassName, + closeAriaLabel, + cssModule, + tag: Tag, + color, + isOpen, + toggle, + children, + transition, + fade, + innerRef, + ...attributes + } = props; + + const classes = mapToCssModules(classNames( + className, + 'alert', + `alert-${color}`, + { 'alert-dismissible': toggle } + ), cssModule); + + const closeClasses = mapToCssModules(classNames('close', closeClassName), cssModule); + + const alertTransition = { + ...Fade.defaultProps, + ...transition, + baseClass: fade ? transition.baseClass : '', + timeout: fade ? transition.timeout : 0, + }; + + return ( + + {toggle ? + + : null} + {children} + + ); +} + +Alert.propTypes = propTypes; +Alert.defaultProps = defaultProps; + +export default Alert; diff --git a/integrationTest/test/jest-react/src/App.css b/integrationTest/test/jest-react/src/App.css deleted file mode 100644 index c5c6e8a68a..0000000000 --- a/integrationTest/test/jest-react/src/App.css +++ /dev/null @@ -1,28 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - animation: App-logo-spin infinite 20s linear; - height: 80px; -} - -.App-header { - background-color: #222; - height: 150px; - padding: 20px; - color: white; -} - -.App-title { - font-size: 1.5em; -} - -.App-intro { - font-size: large; -} - -@keyframes App-logo-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} diff --git a/integrationTest/test/jest-react/src/App.js b/integrationTest/test/jest-react/src/App.js deleted file mode 100644 index 0a9394041c..0000000000 --- a/integrationTest/test/jest-react/src/App.js +++ /dev/null @@ -1,19 +0,0 @@ -import React, { Component } from 'react'; -import './App.css'; - -class App extends Component { - render() { - return ( -
-
-

Welcome to React

-
-

- To get started, edit src/App.js and save to reload. -

-
- ); - } -} - -export default App; diff --git a/integrationTest/test/jest-react/src/App.test.js b/integrationTest/test/jest-react/src/App.test.js deleted file mode 100644 index b84af98d72..0000000000 --- a/integrationTest/test/jest-react/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; - -it('renders without crashing', () => { - const div = document.createElement('div'); - ReactDOM.render(, div); -}); diff --git a/integrationTest/test/jest-react/src/Badge.js b/integrationTest/test/jest-react/src/Badge.js new file mode 100644 index 0000000000..6bfa67c985 --- /dev/null +++ b/integrationTest/test/jest-react/src/Badge.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules } from './utils'; + +const propTypes = { + color: PropTypes.string, + pill: PropTypes.bool, + tag: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + innerRef: PropTypes.oneOfType([PropTypes.object, PropTypes.func, PropTypes.string]), + children: PropTypes.node, + className: PropTypes.string, + cssModule: PropTypes.object, +}; + +const defaultProps = { + color: 'secondary', + pill: false, + tag: 'span' +}; + +const Badge = (props) => { + let { + className, + cssModule, + color, + innerRef, + pill, + tag: Tag, + ...attributes + } = props; + + const classes = mapToCssModules(classNames( + className, + 'badge', + 'badge-' + color, + pill ? 'badge-pill' : false + ), cssModule); + + if (attributes.href && Tag === 'span') { + Tag = 'a'; + } + + return ( + + ); +}; + +Badge.propTypes = propTypes; +Badge.defaultProps = defaultProps; + +export default Badge; diff --git a/integrationTest/test/jest-react/src/Breadcrumb.js b/integrationTest/test/jest-react/src/Breadcrumb.js new file mode 100644 index 0000000000..c24c0ec789 --- /dev/null +++ b/integrationTest/test/jest-react/src/Breadcrumb.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules } from './utils'; + +const propTypes = { + tag: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + listTag: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + className: PropTypes.string, + listClassName: PropTypes.string, + cssModule: PropTypes.object, + children: PropTypes.node, + 'aria-label': PropTypes.string +}; + +const defaultProps = { + tag: 'nav', + listTag: 'ol', + 'aria-label': 'breadcrumb' +}; + +const Breadcrumb = (props) => { + const { + className, + listClassName, + cssModule, + children, + tag: Tag, + listTag: ListTag, + 'aria-label': label, + ...attributes + } = props; + + const classes = mapToCssModules(classNames( + className + ), cssModule); + + const listClasses = mapToCssModules(classNames( + 'breadcrumb', + listClassName + ), cssModule); + + return ( + + + {children} + + + ); +}; + +Breadcrumb.propTypes = propTypes; +Breadcrumb.defaultProps = defaultProps; + +export default Breadcrumb; diff --git a/integrationTest/test/jest-react/src/BreadcrumbItem.js b/integrationTest/test/jest-react/src/BreadcrumbItem.js new file mode 100644 index 0000000000..e9731bd9c9 --- /dev/null +++ b/integrationTest/test/jest-react/src/BreadcrumbItem.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules } from './utils'; + +const propTypes = { + tag: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + active: PropTypes.bool, + className: PropTypes.string, + cssModule: PropTypes.object, +}; + +const defaultProps = { + tag: 'li' +}; + +const BreadcrumbItem = (props) => { + const { + className, + cssModule, + active, + tag: Tag, + ...attributes + } = props; + const classes = mapToCssModules(classNames( + className, + active ? 'active' : false, + 'breadcrumb-item' + ), cssModule); + + return ( + + ); +}; + +BreadcrumbItem.propTypes = propTypes; +BreadcrumbItem.defaultProps = defaultProps; + +export default BreadcrumbItem; diff --git a/integrationTest/test/jest-react/src/Fade.js b/integrationTest/test/jest-react/src/Fade.js new file mode 100644 index 0000000000..76f9c5f8ed --- /dev/null +++ b/integrationTest/test/jest-react/src/Fade.js @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Transition from 'react-transition-group/Transition'; +import { mapToCssModules, omit, pick, TransitionPropTypeKeys, TransitionTimeouts } from './utils'; + +const propTypes = { + ...Transition.propTypes, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node + ]), + tag: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + baseClass: PropTypes.string, + baseClassActive: PropTypes.string, + className: PropTypes.string, + cssModule: PropTypes.object, + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), +}; + +const defaultProps = { + ...Transition.defaultProps, + tag: 'div', + baseClass: 'fade', + baseClassActive: 'show', + timeout: TransitionTimeouts.Fade, + appear: true, + enter: true, + exit: true, + in: true, +}; + +function Fade(props) { + const { + tag: Tag, + baseClass, + baseClassActive, + className, + cssModule, + children, + innerRef, + ...otherProps + } = props; + + // In NODE_ENV=production the Transition.propTypes are wrapped which results in an + // empty object "{}". This is the result of the `react-transition-group` babel + // configuration settings. Therefore, to ensure that production builds work without + // error, we can either explicitly define keys or use the Transition.defaultProps. + // Using the Transition.defaultProps excludes any required props. Thus, the best + // solution is to explicitly define required props in our utilities and reference these. + // This also gives us more flexibility in the future to remove the prop-types + // dependency in distribution builds (Similar to how `react-transition-group` does). + // Note: Without omitting the `react-transition-group` props, the resulting child + // Tag component would inherit the Transition properties as attributes for the HTML + // element which results in errors/warnings for non-valid attributes. + const transitionProps = pick(otherProps, TransitionPropTypeKeys); + const childProps = omit(otherProps, TransitionPropTypeKeys); + + return ( + + {(status) => { + const isActive = status === 'entered'; + const classes = mapToCssModules(classNames( + className, + baseClass, + isActive && baseClassActive + ), cssModule); + return ( + + {children} + + ); + }} + + ); +} + +Fade.propTypes = propTypes; +Fade.defaultProps = defaultProps; + +export default Fade; diff --git a/integrationTest/test/jest-react/src/__tests__/.eslintrc b/integrationTest/test/jest-react/src/__tests__/.eslintrc new file mode 100644 index 0000000000..7ab747f3f7 --- /dev/null +++ b/integrationTest/test/jest-react/src/__tests__/.eslintrc @@ -0,0 +1,18 @@ +{ + "env": { + "jest": true + }, + "globals": { + "createSpyObj": true + }, + "rules": { + "import/no-unresolved": 0, + "import/no-extraneous-dependencies": 0, + "react/no-multi-comp": 0, + "react/prop-types": 0, + "react/no-find-dom-node": 0, + "react/no-array-index-key": 0, + "no-template-curly-in-string": 0, + "jsx-a11y/no-static-element-interactions": 0 + } +} diff --git a/integrationTest/test/jest-react/src/__tests__/Alert.spec.js b/integrationTest/test/jest-react/src/__tests__/Alert.spec.js new file mode 100644 index 0000000000..37bf02fe10 --- /dev/null +++ b/integrationTest/test/jest-react/src/__tests__/Alert.spec.js @@ -0,0 +1,101 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { Alert } from '../'; + +describe('Alert', () => { + it('should render children', () => { + const alert = mount(Yo!); + expect(alert.text()).toBe('Yo!'); + }); + + it('should pass className down', () => { + const alert = mount(Yo!); + expect(alert.find('.alert').hostNodes().prop('className')).toContain('test-class-name'); + }); + + it('should pass close className down', () => { + function noop() { } + const alert = mount(Yo!); + expect(alert.find('.close').hostNodes().prop('className')).toContain('test-class-name'); + }); + + it('should pass other props down', () => { + const alert = mount(Yo!); + expect(alert.find('.alert').hostNodes().prop('data-testprop')).toContain('testvalue'); + }); + + it('should have default transitionTimeouts', () => { + const alert = mount(Yo!); + + const transition = alert.find('Transition'); + expect(transition.prop('timeout')).toEqual(150); + expect(transition.prop('appear')).toBe(true); + expect(transition.prop('enter')).toBe(true); + expect(transition.prop('exit')).toBe(true); + }); + + it('should have support configurable transitionTimeouts', () => { + const alert = mount( + + Yo! + + ); + + const transition = alert.find('Transition'); + expect(transition.prop('timeout')).toEqual(0); + expect(transition.prop('appear')).toBe(false); + expect(transition.prop('enter')).toBe(false); + expect(transition.prop('exit')).toBe(false); + }); + + it('should have "success" as default color', () => { + const alert = mount(Yo!).find('div'); + expect(alert.hasClass('alert-success')).toBe(true); + }); + + it('should accept color prop', () => { + const alert = mount(Yo!).find('div'); + expect(alert.hasClass('alert-warning')).toBe(true); + }); + + it('should use a div tag by default', () => { + const alert = mount(Yo!); + expect(alert.find('div').hostNodes().length).toBe(1); + }); + + it('should be non dismissible by default', () => { + const alert = mount(Yo!).find('div'); + expect(alert.find('button').hostNodes().length).toEqual(0); + expect(alert.hasClass('alert-dismissible')).toBe(false); + }); + + it('should show dismiss button if passed toggle', () => { + const alert = mount( { }}>Yo!).find('div'); + expect(alert.find('button').hostNodes().length).toEqual(1); + expect(alert.hasClass('alert-dismissible')).toBe(true); + }); + + it('should support custom tag', () => { + const alert = mount(Yo!); + expect(alert.find('p').hostNodes().length).toBe(1); + }); + + it('should be empty if not isOpen', () => { + const alert = shallow(Yo!); + expect(alert.html()).toBe(''); + }); + + it('should be dismissible', () => { + const onClick = jest.fn(); + const alert = mount(Yo!); + + alert.find('button').hostNodes().simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); + + it('should render close button with custom aria-label', () => { + const alert = mount( { }} closeAriaLabel="oseclay">Yo!).find('div'); + const closeButton = alert.find('button').hostNodes().first(); + expect(closeButton.prop('aria-label')).toBe('oseclay'); + }); +}); diff --git a/integrationTest/test/jest-react/src/__tests__/Badge.spec.js b/integrationTest/test/jest-react/src/__tests__/Badge.spec.js new file mode 100644 index 0000000000..33c1d67c4d --- /dev/null +++ b/integrationTest/test/jest-react/src/__tests__/Badge.spec.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Badge } from '../'; + +describe('Badge', () => { + it('should render a span by default', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.type()).toBe('span'); + }); + + it('should render an anchor when when href is provided', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.type()).toBe('a'); + }); + + it('should render a custom tag when provided', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.type()).toBe('main'); + }); + + it('should render children', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + }); + + it('should render badges with secondary color', () => { + const wrapper = shallow(Default Badge); + + expect(wrapper.hasClass('badge-secondary')).toBe(true); + }); + + it('should render Badges with other colors', () => { + const wrapper = shallow(Danger Badge); + + expect(wrapper.hasClass('badge-danger')).toBe(true); + }); + + it('should render Badges as pills', () => { + const wrapper = shallow(Pill Badge); + + expect(wrapper.hasClass('badge-pill')).toBe(true); + }); +}); diff --git a/integrationTest/test/jest-react/src/__tests__/Breadcrumb.spec.js b/integrationTest/test/jest-react/src/__tests__/Breadcrumb.spec.js new file mode 100644 index 0000000000..38dbadf547 --- /dev/null +++ b/integrationTest/test/jest-react/src/__tests__/Breadcrumb.spec.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Breadcrumb } from '../'; + +describe('Breadcrumb', () => { + it('should render children', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + }); + + it('should render "nav" by default', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.type()).toBe('nav'); + }); + + it('should render "ol" by default', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.children().type()).toBe('ol'); + }); + + it('should render with the "breadcrumb" class', () => { + const wrapper = shallow(Default Breadcrumb); + + expect(wrapper.children().hasClass('breadcrumb')).toBe(true); + }); + + it('should render custom tag', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.type()).toBe('main'); + }); +}); diff --git a/integrationTest/test/jest-react/src/__tests__/utils.spec.js b/integrationTest/test/jest-react/src/__tests__/utils.spec.js new file mode 100644 index 0000000000..187216c43d --- /dev/null +++ b/integrationTest/test/jest-react/src/__tests__/utils.spec.js @@ -0,0 +1,221 @@ +import * as Utils from '../utils'; + +describe('Utils', () => { + describe('mapToCssModules', () => { + describe('without css module', () => { + it('should return a string', () => { + expect(Utils.mapToCssModules('btn btn-primary')).toEqual(expect.any(String)); + }); + + it('should return the classnames it was given, unchanged', () => { + expect(Utils.mapToCssModules('btn btn-primary')).toBe('btn btn-primary'); + }); + }); + + describe('with css module', () => { + it('should return a string', () => { + const cssModule = { + btn: 'a1', + 'btn-success': 'b1', + 'btn-primary': 'c2', + }; + expect(Utils.mapToCssModules('btn btn-primary', cssModule)).toEqual(expect.any(String)); + }); + + it('should return the mapped classnames', () => { + const cssModule = { + btn: 'a1', + 'btn-success': 'b1', + 'btn-primary': 'c2', + }; + expect(Utils.mapToCssModules('btn btn-primary', cssModule)).toBe('a1 c2'); + }); + + it('should return the original classname when it is not in the map', () => { + const cssModule = { + btn: 'a1', + 'btn-success': 'b1', + }; + expect(Utils.mapToCssModules('btn btn-primary', cssModule)).toBe('a1 btn-primary'); + }); + }); + }); + + describe('omit', () => { + it('should omit keys', () => { + const input = { + hello: 'world', + speed: 'fast', + size: 'small' + }; + expect(Utils.omit(input, ['hello'])).toEqual({ speed: 'fast', size: 'small' }); + }); + + it('should not alter source object', () => { + const input = { + hello: 'world', + speed: 'fast', + size: 'small' + }; + expect(Utils.omit(input, ['hello'])).toEqual({ speed: 'fast', size: 'small' }); + expect(input).toEqual({ + hello: 'world', + speed: 'fast', + size: 'small' + }); + }); + + it('should ignore non-existing keys', () => { + const input = { + hello: 'world', + speed: 'fast', + size: 'small' + }; + expect(Utils.omit(input, ['non-existing', 'hello'])).toEqual({ speed: 'fast', size: 'small' }); + }); + + it('should return a new object', () => { + const input = { + hello: 'world' + }; + // toBe tests equality using `===` and so will test if it's not the same object. + expect(Utils.omit(input, [])).not.toBe(input); + }); + }); + + describe('DOMElement', () => { + it('should not return an error when the prop is an instance of an Element', () => { + const props = { + dom: document.createElement('div'), + }; + const propName = 'dom'; + const componentName = 'ComponentName'; + + expect(Utils.DOMElement(props, propName, componentName)).toBeUndefined(); + }); + + it('should return an error when the prop is NOT an instance of an Element', () => { + const props = { + dom: 'not an Element', + }; + const propName = 'dom'; + const componentName = 'ComponentName'; + + expect(Utils.DOMElement(props, propName, componentName)).toEqual(new Error( + 'Invalid prop `' + propName + '` supplied to `' + componentName + + '`. Expected prop to be an instance of Element. Validation failed.' + )); + }); + }); + + describe('getTarget', () => { + it('should return the result of target if target is a function', () => { + const data = {}; + const spy = jest.fn(() => data); + expect(Utils.getTarget(spy)).toEqual(data); + expect(spy).toHaveBeenCalled(); + }); + + it('should query the document for the target if the target is a string', () => { + const element = document.createElement('div'); + element.className = 'thing'; + document.body.appendChild(element); + jest.spyOn(document, 'querySelectorAll'); + expect(Utils.getTarget('.thing')).toEqual(element); + expect(document.querySelectorAll).toHaveBeenCalledWith('.thing'); + document.querySelectorAll.mockRestore(); + }); + + it('should query the document for the id target if the target is a string and could not be found normally', () => { + const element = document.createElement('div'); + element.setAttribute('id', 'thing'); + document.body.appendChild(element); + jest.spyOn(document, 'querySelectorAll'); + expect(Utils.getTarget('thing')).toEqual(element); + expect(document.querySelectorAll).toHaveBeenCalledWith('#thing'); + document.querySelectorAll.mockRestore(); + }); + + it('should return the input target if it is not a function nor a string', () => { + const target = {}; + expect(Utils.getTarget(target)).toEqual(target); + }); + + it('should not return an error when the target could be identified', () => { + const element = document.createElement('div'); + element.className = 'thing'; + document.body.appendChild(element); + jest.spyOn(document, 'querySelector'); + expect(() => { + Utils.getTarget('.thing'); + }).not.toThrow(); + }); + + it('should return an error when the target could not be identified', () => { + const target = 'not a target'; + expect(() => { + Utils.getTarget(target); + }).toThrow(`The target '${target}' could not be identified in the dom, tip: check spelling`); + }); + + it('should return the value of the `current` object if it is a react Ref object', () => { + const target = { current: { name: 'hello' } }; + expect(Utils.getTarget(target)).toEqual(target.current); + }); + + it('should return null if the `current` property of the target is null', () => { + const target = { current: null }; + expect(Utils.getTarget(target)).toBeNull(); + }); + }); + + describe('setGlobalCssModule', () => { + it('should return the mapped classnames', () => { + const globalCssModule = { + btn: 'a1', + 'btn-success': 'b1', + 'btn-primary': 'c2', + }; + Utils.setGlobalCssModule(globalCssModule); + expect(Utils.mapToCssModules('btn btn-primary')).toBe('a1 c2'); + }); + }); + + // TODO + // describe('getScrollbarWidth', () => { + // // jsdom workaround https://github.com/tmpvar/jsdom/issues/135#issuecomment-68191941 + // Object.defineProperties(window.HTMLElement.prototype, { + // offsetLeft: { + // get: function () { return parseFloat(window.getComputedStyle(this).marginLeft) || 0; } + // }, + // offsetTop: { + // get: function () { return parseFloat(window.getComputedStyle(this).marginTop) || 0; } + // }, + // offsetHeight: { + // get: function () { return parseFloat(window.getComputedStyle(this).height) || 0; } + // }, + // offsetWidth: { + // get: function () { return parseFloat(window.getComputedStyle(this).width) || 0; } + // } + // }); + // + // it('should return scrollbarWidth', () => { + // expect(Utils.getScrollbarWidth()).toBe(); + // }); + // }); + + // TODO verify setScrollbarWidth is called with values when body overflows + // it('should conditionallyUpdateScrollbar when isBodyOverflowing is true', () => { + // const stubbedSetScrollbarWidth = jest.fn().and.callThrough(); + // const prevClientWidth = document.body.clientWidth; + // const prevWindowInnerWidth = window.innerWidth; + // document.body.clientWidth = 100; + // window.innerWidth = 500; + // + // conditionallyUpdateScrollbar(); + // expect(stubbedSetScrollbarWidth).toHaveBeenCalled(); + // + // document.body.clientWidth = prevClientWidth; + // window.innerWidth = prevWindowInnerWidth; + // }); +}); diff --git a/integrationTest/test/jest-react/src/index.css b/integrationTest/test/jest-react/src/index.css deleted file mode 100644 index b4cc7250b9..0000000000 --- a/integrationTest/test/jest-react/src/index.css +++ /dev/null @@ -1,5 +0,0 @@ -body { - margin: 0; - padding: 0; - font-family: sans-serif; -} diff --git a/integrationTest/test/jest-react/src/index.js b/integrationTest/test/jest-react/src/index.js index fae3e3500c..20c17390fc 100644 --- a/integrationTest/test/jest-react/src/index.js +++ b/integrationTest/test/jest-react/src/index.js @@ -1,8 +1,15 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import './index.css'; -import App from './App'; -import registerServiceWorker from './registerServiceWorker'; +import Breadcrumb from './Breadcrumb'; +import BreadcrumbItem from './BreadcrumbItem'; +import Badge from './Badge'; +import Alert from './Alert'; +import Fade from './Fade'; +import * as Util from 'util'; -ReactDOM.render(, document.getElementById('root')); -registerServiceWorker(); +export { + Alert, + Fade, + Breadcrumb, + BreadcrumbItem, + Badge, + Util +}; diff --git a/integrationTest/test/jest-react/src/setupTests.js b/integrationTest/test/jest-react/src/setupTests.js new file mode 100644 index 0000000000..26b471ee31 --- /dev/null +++ b/integrationTest/test/jest-react/src/setupTests.js @@ -0,0 +1,27 @@ +/* global jest */ +/* eslint-disable import/no-extraneous-dependencies */ +import Enzyme, { ReactWrapper } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +Enzyme.configure({ adapter: new Adapter() }); + +// TODO remove when enzyme releases https://github.com/airbnb/enzyme/pull/1179 +ReactWrapper.prototype.hostNodes = function () { + return this.filterWhere(n => typeof n.type() === 'string'); +}; +global.requestAnimationFrame = function (cb) { cb(0); }; +global.window.cancelAnimationFrame = function () { }; +global.createSpyObj = (baseName, methodNames) => { + const obj = {}; + + for (let i = 0; i < methodNames.length; i += 1) { + obj[methodNames[i]] = jest.fn(); + } + + return obj; +}; +global.document.createRange = () => ({ + setStart: () => {}, + setEnd: () => {}, + commonAncestorContainer: {} +}); diff --git a/integrationTest/test/jest-react/src/utils.js b/integrationTest/test/jest-react/src/utils.js new file mode 100644 index 0000000000..9343b99724 --- /dev/null +++ b/integrationTest/test/jest-react/src/utils.js @@ -0,0 +1,301 @@ +import isFunction from 'lodash.isfunction'; +import PropTypes from 'prop-types'; + +// https://github.com/twbs/bootstrap/blob/v4.0.0-alpha.4/js/src/modal.js#L436-L443 +export function getScrollbarWidth() { + let scrollDiv = document.createElement('div'); + // .modal-scrollbar-measure styles // https://github.com/twbs/bootstrap/blob/v4.0.0-alpha.4/scss/_modal.scss#L106-L113 + scrollDiv.style.position = 'absolute'; + scrollDiv.style.top = '-9999px'; + scrollDiv.style.width = '50px'; + scrollDiv.style.height = '50px'; + scrollDiv.style.overflow = 'scroll'; + document.body.appendChild(scrollDiv); + const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth; + document.body.removeChild(scrollDiv); + return scrollbarWidth; +} + +export function setScrollbarWidth(padding) { + document.body.style.paddingRight = padding > 0 ? `${padding}px` : null; +} + +export function isBodyOverflowing() { + return document.body.clientWidth < window.innerWidth; +} + +export function getOriginalBodyPadding() { + const style = window.getComputedStyle(document.body, null); + + return parseInt((style && style.getPropertyValue('padding-right')) || 0, 10); +} + +export function conditionallyUpdateScrollbar() { + const scrollbarWidth = getScrollbarWidth(); + // https://github.com/twbs/bootstrap/blob/v4.0.0-alpha.6/js/src/modal.js#L433 + const fixedContent = document.querySelectorAll( + '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top' + )[0]; + const bodyPadding = fixedContent + ? parseInt(fixedContent.style.paddingRight || 0, 10) + : 0; + + if (isBodyOverflowing()) { + setScrollbarWidth(bodyPadding + scrollbarWidth); + } +} + +let globalCssModule; + +export function setGlobalCssModule(cssModule) { + globalCssModule = cssModule; +} + +export function mapToCssModules(className = '', cssModule = globalCssModule) { + if (!cssModule) return className; + return className + .split(' ') + .map(c => cssModule[c] || c) + .join(' '); +} + +/** + * Returns a new object with the key/value pairs from `obj` that are not in the array `omitKeys`. + */ +export function omit(obj, omitKeys) { + const result = {}; + Object.keys(obj).forEach((key) => { + if (omitKeys.indexOf(key) === -1) { + result[key] = obj[key]; + } + }); + return result; +} + +/** + * Returns a filtered copy of an object with only the specified keys. + */ +export function pick(obj, keys) { + const pickKeys = Array.isArray(keys) ? keys : [keys]; + let length = pickKeys.length; + let key; + const result = {}; + + while (length > 0) { + length -= 1; + key = pickKeys[length]; + result[key] = obj[key]; + } + return result; +} + +let warned = {}; + +export function warnOnce(message) { + if (!warned[message]) { + /* istanbul ignore else */ + if (typeof console !== 'undefined') { + console.error(message); // eslint-disable-line no-console + } + warned[message] = true; + } +} + +export function deprecated(propType, explanation) { + return function validate(props, propName, componentName, ...rest) { + if (props[propName] !== null && typeof props[propName] !== 'undefined') { + warnOnce( + `"${propName}" property of "${componentName}" has been deprecated.\n${explanation}` + ); + } + + return propType(props, propName, componentName, ...rest); + }; +} + +export function DOMElement(props, propName, componentName) { + if (!(props[propName] instanceof Element)) { + return new Error( + 'Invalid prop `' + + propName + + '` supplied to `' + + componentName + + '`. Expected prop to be an instance of Element. Validation failed.' + ); + } +} + +export const targetPropType = PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + DOMElement, + PropTypes.shape({ current: PropTypes.any }) +]); + + +/* eslint key-spacing: ["error", { afterColon: true, align: "value" }] */ +// These are all setup to match what is in the bootstrap _variables.scss +// https://github.com/twbs/bootstrap/blob/v4-dev/scss/_variables.scss +export const TransitionTimeouts = { + Fade: 150, // $transition-fade + Collapse: 350, // $transition-collapse + Modal: 300, // $modal-transition + Carousel: 600 // $carousel-transition +}; + +// Duplicated Transition.propType keys to ensure that Reactstrap builds +// for distribution properly exclude these keys for nested child HTML attributes +// since `react-transition-group` removes propTypes in production builds. +export const TransitionPropTypeKeys = [ + 'in', + 'mountOnEnter', + 'unmountOnExit', + 'appear', + 'enter', + 'exit', + 'timeout', + 'onEnter', + 'onEntering', + 'onEntered', + 'onExit', + 'onExiting', + 'onExited' +]; + +export const TransitionStatuses = { + ENTERING: 'entering', + ENTERED: 'entered', + EXITING: 'exiting', + EXITED: 'exited' +}; + +export const keyCodes = { + esc: 27, + space: 32, + enter: 13, + tab: 9, + up: 38, + down: 40 +}; + +export const PopperPlacements = [ + 'auto-start', + 'auto', + 'auto-end', + 'top-start', + 'top', + 'top-end', + 'right-start', + 'right', + 'right-end', + 'bottom-end', + 'bottom', + 'bottom-start', + 'left-end', + 'left', + 'left-start' +]; + +export const canUseDOM = !!( + typeof window !== 'undefined' && + window.document && + window.document.createElement +); + +export function isReactRefObj(target) { + if (target && typeof target === 'object') { + return 'current' in target; + } + return false; +} + +export function findDOMElements(target) { + if (isReactRefObj(target)) { + return target.current; + } + if (isFunction(target)) { + return target(); + } + if (typeof target === 'string' && canUseDOM) { + let selection = document.querySelectorAll(target); + if (!selection.length) { + selection = document.querySelectorAll(`#${target}`); + } + if (!selection.length) { + throw new Error( + `The target '${target}' could not be identified in the dom, tip: check spelling` + ); + } + return selection; + } + return target; +} + +export function isArrayOrNodeList(els) { + if (els === null) { + return false; + } + return Array.isArray(els) || (canUseDOM && typeof els.length === 'number'); +} + + +export function getTarget(target) { + const els = findDOMElements(target); + if (isArrayOrNodeList(els)) { + return els[0]; + } + return els; +} + +export const defaultToggleEvents = ['touchstart', 'click']; + +export function addMultipleEventListeners(_els, handler, _events) { + let els = _els; + if (!isArrayOrNodeList(els)) { + els = [els]; + } + + let events = _events; + if (typeof events === 'string') { + events = events.split(/\s+/); + } + + if ( + !isArrayOrNodeList(els) || + typeof handler !== 'function' || + !Array.isArray(events) + ) { + throw new Error(` + The first argument of this function must be DOM node or an array on DOM nodes or NodeList. + The second must be a function. + The third is a string or an array of strings that represents DOM events + `); + } + Array.prototype.forEach.call(events, (event) => { + Array.prototype.forEach.call(els, (el) => { + el.addEventListener(event, handler); + }); + }); + return function removeEvents() { + Array.prototype.forEach.call(events, (event) => { + Array.prototype.forEach.call(els, (el) => { + el.removeEventListener(event, handler); + }); + }); + }; +} + +export const focusableElements = [ + 'a[href]', + 'area[href]', + 'input:not([disabled]):not([type=hidden])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + 'object', + 'embed', + '[tabindex]:not(.modal)', + 'audio[controls]', + 'video[controls]', + '[contenteditable]:not([contenteditable="false"])', +]; diff --git a/integrationTest/test/jest-react/stryker.conf.js b/integrationTest/test/jest-react/stryker.conf.js index d962f2562e..67acfb16e6 100644 --- a/integrationTest/test/jest-react/stryker.conf.js +++ b/integrationTest/test/jest-react/stryker.conf.js @@ -5,8 +5,9 @@ module.exports = function (config) { transpilers: [], reporters: ["event-recorder"], coverageAnalysis: "off", - mutate: ["src/**/*.js", "!src/**/*.test.js"], - maxConcurrentTestRunners: 1, + timeoutMS: 60000, // High timeout to survive high build server load. Mutants created here should never timeout + mutate: ["src/{Alert,Badge,Breadcrumb}.js"], + maxConcurrentTestRunners: 2, jest: { projectType: 'react' } diff --git a/integrationTest/test/jest-react/verify/verify.ts b/integrationTest/test/jest-react/verify/verify.ts index ed977e2782..6919747335 100644 --- a/integrationTest/test/jest-react/verify/verify.ts +++ b/integrationTest/test/jest-react/verify/verify.ts @@ -1,19 +1,30 @@ -import { fsAsPromised } from '@stryker-mutator/util'; import { expect } from 'chai'; -import * as path from 'path'; -import { ScoreResult } from 'stryker-api/report'; +import { readScoreResult } from '../../../helpers'; -describe('After running stryker on angular project', () => { - it('should report 50% mutation score', async () => { - const eventsDir = path.resolve(__dirname, '..', 'reports', 'mutation', 'events'); - const allReportFiles = await fsAsPromised.readdir(eventsDir); - const scoreResultReportFile = allReportFiles.find(file => !!file.match(/.*onScoreCalculated.*/)); - expect(scoreResultReportFile).ok; - const scoreResultContent = await fsAsPromised.readFile(path.resolve(eventsDir, scoreResultReportFile || ''), 'utf8'); - const scoreResult = JSON.parse(scoreResultContent) as ScoreResult; - expect(scoreResult.killed).eq(1); - expect(scoreResult.survived).eq(1); - - expect(scoreResult.mutationScore).greaterThan(49).and.lessThan(51); +describe('After running stryker on jest-react project', () => { + it('should report expected scores', async () => { + const actualResult = await readScoreResult(); + expect(actualResult.childResults).lengthOf(3); + expect({ + killed: actualResult.killed, + mutationScore: parseFloat(actualResult.mutationScore.toFixed(2)), + noCoverage: actualResult.noCoverage, + survived: actualResult.survived, + timedOut: actualResult.timedOut, + totalMutants: actualResult.totalMutants + }).deep.eq({ + killed: 34, + mutationScore: 64.15, + noCoverage: 0, + survived: 19, + timedOut: 0, + totalMutants: 53, + }); + /* + ---------------|---------|----------|-----------|------------|----------|---------| + File | % score | # killed | # timeout | # survived | # no cov | # error | + ---------------|---------|----------|-----------|------------|----------|---------| + All files | 64.15 | 34 | 0 | 19 | 0 | 0 | + ---------------|---------|----------|-----------|------------|----------|---------|*/ }); }); diff --git a/integrationTest/test/typescript-transpiling/stryker.log b/integrationTest/test/typescript-transpiling/stryker.log deleted file mode 100644 index e69de29bb2..0000000000