diff --git a/README.md b/README.md index 47ab4a65d29317d22e41ccbd91d0f3a8dfd7f41e..b478b1e47504962bd77f2cc115da6e57c2dabea1 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,25 @@ npm start Then go to [http://localhost:8601/](http://localhost:8601/) - the playground outputs the default GUI component ## Testing +NOTE: If you're a windows user, please run these scripts in Windows `cmd.exe` instead of Git Bash/MINGW64. + +Run linter, unit tests, and build. ```bash npm test ``` +Run unit tests in isolation. +```bash +npm run unit-test +``` + +Run unit tests in watch mode (watches for code changes and continuously runs tests). See [jest cli docs](https://facebook.github.io/jest/docs/en/cli.html#content) for more options. +```bash +npm run unit-test -- --watch +``` + +You may want to review the documentation for [Jest](https://facebook.github.io/jest/docs/en/api.html) and [Enzyme](http://airbnb.io/enzyme/docs/api/) as you write your tests. + ## Publishing to GitHub Pages You can publish the GUI to github.io so that others on the Internet can view it. diff --git a/package.json b/package.json index bb5cd437aff3d7f6c83bacadbab4c434ed68ba14..4d41d6b2bdf4d0c61a84eb10f9ab9155f969e963 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "i18n:src": "babel src > tmp.js && rimraf tmp.js && ./scripts/build-i18n-source.js ./translations/messages/ ./translations/", "lint": "eslint . --ext .js,.jsx", "start": "npm run i18n:msgs && webpack-dev-server", - "test": "npm run lint && npm run build", + "unit-test": "jest", + "test": "npm run lint && npm run unit-test && npm run build", "watch": "webpack --progress --colors --watch" }, "author": "Massachusetts Institute of Technology", @@ -39,12 +40,14 @@ "classnames": "2.2.5", "copy-webpack-plugin": "4.0.1", "css-loader": "0.28.3", + "enzyme": "^2.8.2", "eslint": "^3.16.1", "eslint-config-scratch": "^3.0.0", "eslint-plugin-react": "^7.0.1", "gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder", "html-webpack-plugin": "2.28.0", "immutable": "3.8.1", + "jest": "^20.0.4", "lodash.bindall": "4.4.0", "lodash.debounce": "4.0.8", "lodash.defaultsdeep": "4.6.0", @@ -65,6 +68,8 @@ "react-modal": "2.2.2", "react-redux": "5.0.5", "react-style-proptype": "3.0.0", + "react-test-renderer": "^15.5.4", + "redux-mock-store": "^1.2.3", "react-tabs": "1.1.0", "redux": "3.7.0", "redux-throttle": "0.1.1", @@ -81,5 +86,14 @@ "webpack": "^2.4.1", "webpack-dev-server": "^2.4.1", "xhr": "2.4.0" + }, + "jest": { + "testPathIgnorePatterns": [ + "src/test.js" + ], + "moduleNameMapper": { + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/test/__mocks__/fileMock.js", + "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js" + } } } diff --git a/test/__mocks__/fileMock.js b/test/__mocks__/fileMock.js new file mode 100644 index 0000000000000000000000000000000000000000..59890f6a201a549387cb636d94e8159cfed59b33 --- /dev/null +++ b/test/__mocks__/fileMock.js @@ -0,0 +1,3 @@ +// __mocks__/fileMock.js + +module.exports = 'test-file-stub'; diff --git a/test/__mocks__/styleMock.js b/test/__mocks__/styleMock.js new file mode 100644 index 0000000000000000000000000000000000000000..d988e23b7397f73ea6eacfbfd8bef34aa0782bb3 --- /dev/null +++ b/test/__mocks__/styleMock.js @@ -0,0 +1,3 @@ +// __mocks__/styleMock.js + +module.exports = {}; diff --git a/test/unit/components/__snapshots__/button.test.jsx.snap b/test/unit/components/__snapshots__/button.test.jsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..5b928b4c4fedbcafec4e60f941f6497f565cb5ac --- /dev/null +++ b/test/unit/components/__snapshots__/button.test.jsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ButtonComponent matches snapshot 1`] = ` +<span + className="" + onClick={[Function]} + role="button" +/> +`; diff --git a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..39146df349974556185db47203ad60931acb5c74 --- /dev/null +++ b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SpriteSelectorItemComponent matches snapshot when selected 1`] = ` +<div + className="ponies undefined" + onClick={[Function]} + style={ + Object { + "alignContent": undefined, + "alignItems": undefined, + "alignSelf": undefined, + "flexBasis": undefined, + "flexDirection": undefined, + "flexGrow": undefined, + "flexShrink": undefined, + "flexWrap": undefined, + "height": undefined, + "justifyContent": undefined, + "width": undefined, + } + } +> + <div + className="" + onClick={[Function]} + > + <img + className={undefined} + src="test-file-stub" + /> + </div> + <canvas + className={undefined} + height={32} + width={32} + /> + <div + className={undefined} + > + Pony sprite + </div> +</div> +`; diff --git a/test/unit/components/button.test.jsx b/test/unit/components/button.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..80b20f50f7777fd38fcd2a4b51b47f27f08eb8dd --- /dev/null +++ b/test/unit/components/button.test.jsx @@ -0,0 +1,24 @@ +/* eslint-env jest */ +const React = require('react'); // eslint-disable-line no-unused-vars +const {shallow} = require('enzyme'); +const ButtonComponent = require('../../../src/components/button/button'); // eslint-disable-line no-unused-vars +const renderer = require('react-test-renderer'); + +describe('ButtonComponent', () => { + test('matches snapshot', () => { + const onClick = jest.fn(); + const component = renderer.create( + <ButtonComponent onClick={onClick}/> + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + + test('triggers callback when clicked', () => { + const onClick = jest.fn(); + const componentShallowWrapper = shallow( + <ButtonComponent onClick={onClick}/> + ); + componentShallowWrapper.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/components/sprite-selector-item.test.jsx b/test/unit/components/sprite-selector-item.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2e60f09b1949bf6edce96bc28fcc92b907107df3 --- /dev/null +++ b/test/unit/components/sprite-selector-item.test.jsx @@ -0,0 +1,72 @@ +/* eslint-env jest */ +const React = require('react'); // eslint-disable-line no-unused-vars +const {shallow} = require('enzyme'); +const SpriteSelectorItemComponent = require( // eslint-disable-line no-unused-vars + '../../../src/components/sprite-selector-item/sprite-selector-item'); +const CostumeCanvas = require( // eslint-disable-line no-unused-vars + '../../../src/components/costume-canvas/costume-canvas'); +const CloseButton = require('../../../src/components/close-button/close-button'); // eslint-disable-line no-unused-vars +const renderer = require('react-test-renderer'); + +describe('SpriteSelectorItemComponent', () => { + let className; + let costumeURL; + let name; + let onClick; + let onDeleteButtonClick; + let selected; + + // Wrap this in a function so it gets test specific states and can be reused. + const getComponent = function () { + return <SpriteSelectorItemComponent + className={className} + costumeURL={costumeURL} + name={name} + onClick={onClick} + onDeleteButtonClick={onDeleteButtonClick} + selected={selected}/>; + }; + + beforeEach(() => { + className = 'ponies'; + costumeURL = 'https://scratch.mit.edu/foo/bar/pony'; + name = 'Pony sprite'; + onClick = jest.fn(); + onDeleteButtonClick = jest.fn(); + selected = true; + }); + + test('matches snapshot when selected', () => { + const component = renderer.create(getComponent()); + expect(component.toJSON()).toMatchSnapshot(); + }); + + test('does not have a close box when not selected', () => { + selected = false; + const componentShallowWrapper = shallow(getComponent()); + expect(componentShallowWrapper.find(CloseButton).exists()).toBe(false); + }); + + test('triggers callback when Box component is clicked', () => { + const componentShallowWrapper = shallow(getComponent()); + componentShallowWrapper.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); + + test('triggers callback when CloseButton component is clicked', () => { + const componentShallowWrapper = shallow(getComponent()); + componentShallowWrapper.find(CloseButton).simulate('click'); + expect(onDeleteButtonClick).toHaveBeenCalled(); + }); + + test('creates a CostumeCanvas when a costume url is defined', () => { + const componentShallowWrapper = shallow(getComponent()); + expect(componentShallowWrapper.find(CostumeCanvas).exists()).toBe(true); + }); + + test('does not create a CostumeCanvas when a costume url is null', () => { + costumeURL = null; + const componentShallowWrapper = shallow(getComponent()); + expect(componentShallowWrapper.find(CostumeCanvas).exists()).toBe(false); + }); +}); diff --git a/test/unit/containers/__snapshots__/green-flag.test.jsx.snap b/test/unit/containers/__snapshots__/green-flag.test.jsx.snap new file mode 100644 index 0000000000000000000000000000000000000000..21b5a43f6cf495b6037be21f65e559b47aed4ecd --- /dev/null +++ b/test/unit/containers/__snapshots__/green-flag.test.jsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GreenFlag Container renders active state 1`] = ` +<img + className="undefined" + onClick={[Function]} + src="test-file-stub" + title="Go" +/> +`; + +exports[`GreenFlag Container renders inactive state 1`] = ` +<img + className="" + onClick={[Function]} + src="test-file-stub" + title="Go" +/> +`; diff --git a/test/unit/containers/green-flag.test.jsx b/test/unit/containers/green-flag.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3c6ebfe9bf9f7f2b787d91fd13745f40a4651067 --- /dev/null +++ b/test/unit/containers/green-flag.test.jsx @@ -0,0 +1,40 @@ +/* eslint-env jest */ +const React = require('react'); // eslint-disable-line no-unused-vars +const {shallow} = require('enzyme'); +const GreenFlag = require('../../../src/containers/green-flag'); // eslint-disable-line no-unused-vars +const renderer = require('react-test-renderer'); +const VM = require('scratch-vm'); + +describe('GreenFlag Container', () => { + let vm; + beforeEach(() => { + vm = new VM(); + }); + + test('renders active state', () => { + const component = renderer.create( + <GreenFlag active={true} vm={vm}/> + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + + test('renders inactive state', () => { + const component = renderer.create( + <GreenFlag active={false} vm={vm}/> + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + + test('triggers onClick when active', () => { + const onClick = jest.fn(); + const componentShallowWrapper = shallow( + <GreenFlag active={true} onClick={onClick} vm={vm}/> + ); + componentShallowWrapper.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); + + // @todo: Test for handles key events. + // @todo: Test project run start. + // @todo: Test project run stop. +}); diff --git a/test/unit/containers/sprite-selector-item.test.jsx b/test/unit/containers/sprite-selector-item.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9e62199b1410c9ef46fb7cd4fb930cd14ba967fa --- /dev/null +++ b/test/unit/containers/sprite-selector-item.test.jsx @@ -0,0 +1,60 @@ +/* eslint-env jest */ +const React = require('react'); // eslint-disable-line no-unused-vars +const {mount} = require('enzyme'); +import configureStore from 'redux-mock-store'; +import {Provider} from 'react-redux'; // eslint-disable-line no-unused-vars + +const SpriteSelectorItem = require( // eslint-disable-line no-unused-vars + '../../../src/containers/sprite-selector-item'); +const CloseButton = require('../../../src/components/close-button/close-button'); // eslint-disable-line no-unused-vars + +describe('SpriteSelectorItem Container', () => { + const mockStore = configureStore(); + let className; + let costumeURL; + let name; + let onClick; + let onDeleteButtonClick; + let selected; + let id; + let store; + // Wrap this in a function so it gets test specific states and can be reused. + const getContainer = function () { + return <Provider store={store}><SpriteSelectorItem + className={className} + costumeURL={costumeURL} + id={id} + name={name} + onClick={onClick} + onDeleteButtonClick={onDeleteButtonClick} + selected={selected}/></Provider>; + }; + + beforeEach(() => { + store = mockStore(); + className = 'ponies'; + costumeURL = 'https://scratch.mit.edu/foo/bar/pony'; + id = 1337; + name = 'Pony sprite'; + onClick = jest.fn(); + onDeleteButtonClick = jest.fn(); + selected = true; + // Mock window.confirm() which is called when the close button is clicked. + global.confirm = jest.fn(() => true); + }); + + test('should confirm if the user really wants to delete the sprite', () => { + const wrapper = mount(getContainer()); + wrapper.find(CloseButton).simulate('click'); + expect(global.confirm).toHaveBeenCalled(); + expect(onDeleteButtonClick).toHaveBeenCalledWith(1337); + }); + + test('should not delete the sprite if the user cancels', () => { + global.confirm = jest.fn(() => false); + const wrapper = mount(getContainer()); + wrapper.find(CloseButton).simulate('click'); + expect(global.confirm).toHaveBeenCalled(); + expect(onDeleteButtonClick).not.toHaveBeenCalled(); + }); +});