From 9a1a7fd1e3608080a0dbb618108eab5331075cac Mon Sep 17 00:00:00 2001 From: Oliver Viljamaa Date: Wed, 5 Dec 2018 11:59:18 +0000 Subject: [PATCH] Change mount signature and only allow components with one-way bound props --- .eslintrc | 6 +- CHANGELOG.md | 9 ++ README.md | 118 ++++++++++-------- example.test.js | 11 +- package-lock.json | 6 +- package.json | 6 +- src/common/utils/utils.js | 3 + src/common/utils/utils.spec.js | 12 +- src/mockComponent/mockComponent.js | 4 +- src/mockComponent/mockComponent.spec.js | 58 +++++---- .../TestElementWrapper/TestElementWrapper.js | 1 + .../TestElementWrapper.spec.js | 54 +++++--- src/mount/component/component.js | 34 +++++ src/mount/component/component.spec.js | 86 +++++++++++++ src/mount/component/index.js | 1 + src/mount/mount.js | 25 ++-- src/mount/mount.spec.js | 63 +++++----- src/mount/template/index.js | 1 + src/mount/template/template.js | 18 +++ src/mount/template/template.spec.js | 36 ++++++ src/mount/validation/AngularjsEnzymeError.js | 5 + .../validation/AngularjsEnzymeError.spec.js | 9 ++ src/mount/validation/index.js | 1 + src/mount/validation/validation.js | 23 ++++ src/mount/validation/validation.spec.js | 23 ++++ 25 files changed, 447 insertions(+), 166 deletions(-) create mode 100644 src/mount/component/component.js create mode 100644 src/mount/component/component.spec.js create mode 100644 src/mount/component/index.js create mode 100644 src/mount/template/index.js create mode 100644 src/mount/template/template.js create mode 100644 src/mount/template/template.spec.js create mode 100644 src/mount/validation/AngularjsEnzymeError.js create mode 100644 src/mount/validation/AngularjsEnzymeError.spec.js create mode 100644 src/mount/validation/index.js create mode 100644 src/mount/validation/validation.js create mode 100644 src/mount/validation/validation.spec.js diff --git a/.eslintrc b/.eslintrc index f30f47e..0823fc9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,9 +6,7 @@ "rules": { "no-underscore-dangle": "off", "no-use-before-define": "off", - "no-param-reassign": "off" - }, - "globals": { - "angular": false + "no-param-reassign": "off", + "import/prefer-default-export": "off" } } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ebe2f..6cb361d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# v2.0.0 +## Change `mount` signature and only allow components with one-way bound props + +Breaking: +* `mount` now takes a `tagName` as the first argument rather than a `template` +* only components with onw-way bound props are allowed (bear in mind that this affects all callbacks previously bound with `&`) + +See [README](README.md#mounttagname-props-options--testelementwrapper). + # v1.2.2 ## Expose `mock._template` and `mock._name` for custom matchers diff --git a/README.md b/README.md index a8d3c96..438e724 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Therefore, it is well suited for organisations and individuals **moving from Ang [**An example showing the utility in use can be found here.**](example.test.js) Available methods: -[`mount`](#mounttemplate-props--testelementwrapper) +[`mount`](#mounttagname-props--testelementwrapper) [`mockComponent`](#mockcomponentname--mock) Returned classes: @@ -34,28 +34,29 @@ import { mount, mockComponent } from 'angularjs-enzyme'; ## API -### `mount(template[, props]) => TestElementWrapper` +### `mount(tagName[, props]) => TestElementWrapper` -Mounts the `template` (`String`) with optional `props` (`Object`) and returns a [`TestElementWrapper`](#testelementwrapper-api) with numerous helper methods. The props are attached to the `$ctrl` available in the template scope. +Mounts the component with `tagName` (`String`) and optional `props` (`Object`) and returns a [`TestElementWrapper`](#testelementwrapper-api) with numerous helper methods. The props are attached to the `$ctrl` available in the template scope. Only components with one-way bound props (`<`) can be mounted.
Example +*some-component.html* +```html +

{{ $ctrl.title }}

+

{{ $ctrl.text }}

+``` + ```js import 'angular'; import 'angular-mocks'; import { mount } from 'angularjs-enzyme'; describe('Component under test', () => { - const TEMPLATE = ` -

{{ $ctrl.title }}

-

{{ $ctrl.text }}

- `; - let component; beforeEach(() => { angular.mock.module('moduleOfComponentUnderTest'); - component = mount(TEMPLATE, { title: 'A title', text: 'Some text' }); + component = mount('some-component', { title: 'A title', text: 'Some text' }); }); }); ``` @@ -94,6 +95,7 @@ The number of elements in the wrapper.
Example +*some-component.html* ```html
  • 1
  • @@ -117,6 +119,7 @@ Returns HTML of the wrapper. It should only be used for logging purposes, in tes
    Example +*some-component.html* ```html

    Some title

    ``` @@ -134,6 +137,7 @@ it('renders title as html', () => {
    Example +*some-component.html* ```html

    Some title

    Some text

    @@ -154,6 +158,7 @@ Returns whether the wrapper has a class with `className` (`String`) or not.
    Example +*some-component.html* ```html ``` @@ -177,6 +182,7 @@ Returns whether or not the wrapper contains any elements.
    Example +*some-component.html* ```html ``` @@ -200,6 +206,7 @@ Returns a [`TestElementWrapper`](#testelementwrapper-api) (for chaining) with ev
    Example +*some-component.html* ```html
    Wrong @@ -229,6 +236,7 @@ Returns a [`TestElementWrapper`](#testelementwrapper-api) (for chaining) for the
    Example +*some-component.html* ```html @@ -251,6 +259,7 @@ Returns a [`TestElementWrapper`](#testelementwrapper-api) (for chaining) for ele
    Example +*some-component.html* ```html @@ -273,6 +282,7 @@ Maps the nodes in the wrapper to another array using `fn` (`Function`).
    Example +*some-component.html* ```html
    • One
    • @@ -298,6 +308,7 @@ Returns all wrapper props/attributes.
      Example +*some-component.html* ```html Send money ``` @@ -320,6 +331,7 @@ Returns wrapper prop/attribute value with provided `key` (`String`).
      Example +*some-component.html* ```html Send money ``` @@ -341,10 +353,11 @@ NOTE: `event` should be written in camelCase and without the `on` present in the
      Example +*some-component.html* ```html

      {{ $ctrl.text }}

      - + ``` ```js @@ -352,15 +365,7 @@ let component; let onClick; beforeEach(() => { onClick = jest.fn(); - component = mount( - ` - - `, - { text: 'Original text', onClick }, - ); + component = mount('some-component', { text: 'Original text', onClick }); }); it('calls click handler on button click', () => { @@ -391,6 +396,7 @@ Merges `props` (`Object`) with existing props and updates view to reflect them,
      Example +*some-component.html* ```html

      {{ $ctrl.title }}

      {{ $ctrl.text }}

      @@ -398,15 +404,10 @@ Merges `props` (`Object`) with existing props and updates view to reflect them, ```js it('changes title and text when props change', () => { - const component = mount( - ` - - `, - { title: 'Original title', text: 'Original text' }, - ); + const component = mount('some-component', { + title: 'Original title', + text: 'Original text', + }); const title = () => component.find('h1').text(); const text = () => component.find('p').text(); @@ -430,15 +431,18 @@ Returns whether or not the mocked component exists in the rendered template.
      Example +*some-component.html* +```html + + +``` + ```js let component; beforeEach(() => { - component = mount(` - - - `); + component = mount('some-component'); }); it('allows toggling child component', () => { @@ -461,16 +465,19 @@ Returns all mocked component props.
      Example +*some-component.html* +```html +
      Something else
      + +``` + ```js let component; beforeEach(() => { - component = mount(` -
      Something else
      - - `); + component = mount('some-component'); }); it('passes props to child component', () => { @@ -490,13 +497,16 @@ Returns mocked component prop value with the provided `key` (`String`).
      Example +*some-component.html* +```html +
      Something else
      + +``` + ```js let component; beforeEach(() => { - component = mount(` -
      Something else
      - - `); + component = mount('some-component'); }); it('passes some prop to child component', () => { @@ -515,18 +525,18 @@ NOTE: `event` should be written in camelCase and without the `on` present in the
      Example +*some-component.html* +```html +
      Something else
      + +``` + ```js it('calls parent component with data when child component is called', () => { const onSomePropChange = jest.fn(); - mount( - ` -
      Something else
      - - `, - { onSomePropChange }, // ⇦ props for component under test - ); + mount('some-component', { onSomePropChange }); expect(onSomePropChange).not.toBeCalled(); childComponent.simulate('somePropChange', 'New value'); diff --git a/example.test.js b/example.test.js index d9dbd76..9736bf8 100644 --- a/example.test.js +++ b/example.test.js @@ -1,4 +1,4 @@ -import 'angular'; +import angular from 'angular'; import 'angular-mocks'; import { mount } from './src/main'; @@ -25,14 +25,7 @@ describe('Shopping list', () => { beforeEach(() => { angular.mock.module('shoppingList'); - component = mount( - ` - - `, - ); + component = mount('shopping-list'); }); it('has no list when no items are passed', () => { diff --git a/package-lock.json b/package-lock.json index e9836d0..57cbf74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1039,14 +1039,12 @@ "angular": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/angular/-/angular-1.7.5.tgz", - "integrity": "sha512-760183yxtGzni740IBTieNuWLtPNAoMqvmC0Z62UoU0I3nqk+VJuO3JbQAXOyvo3Oy/ZsdNQwrSTh/B0OQZjNw==", - "dev": true + "integrity": "sha512-760183yxtGzni740IBTieNuWLtPNAoMqvmC0Z62UoU0I3nqk+VJuO3JbQAXOyvo3Oy/ZsdNQwrSTh/B0OQZjNw==" }, "angular-mocks": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/angular-mocks/-/angular-mocks-1.7.5.tgz", - "integrity": "sha512-I+Ue2Bkx6R9W5178DYrNvzjIdGh4wKKoCWsgz8dc7ysH4mA70Q3M9v5xRF0RUu7r+2CZj+nDeUecvh2paxcYvg==", - "dev": true + "integrity": "sha512-I+Ue2Bkx6R9W5178DYrNvzjIdGh4wKKoCWsgz8dc7ysH4mA70Q3M9v5xRF0RUu7r+2CZj+nDeUecvh2paxcYvg==" }, "ansi-escapes": { "version": "3.1.0", diff --git a/package.json b/package.json index 0b7f335..7596aa8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angularjs-enzyme", - "version": "1.2.2", + "version": "2.0.0", "main": "index.js", "files": [ "dist/" @@ -33,8 +33,6 @@ "@babel/core": "^7.1.5", "@babel/plugin-proposal-object-rest-spread": "^7.0.0", "@babel/preset-env": "^7.1.5", - "angular": "^1.7.5", - "angular-mocks": "^1.7.5", "babel-core": "^7.0.0-bridge.0", "eslint": "^5.8.0", "eslint-config-airbnb-base": "^13.1.0", @@ -60,6 +58,8 @@ ] }, "dependencies": { + "angular": "^1.7.5", + "angular-mocks": "^1.7.5", "core-js": "^2.5.7", "lodash": "^4.17.11" }, diff --git a/src/common/utils/utils.js b/src/common/utils/utils.js index 98d4df5..45e5cfb 100644 --- a/src/common/utils/utils.js +++ b/src/common/utils/utils.js @@ -2,3 +2,6 @@ export const compose = (...functions) => functions.reduce((f, g) => (...args) => export const convertKebabCaseToCamelCase = string => string.replace(/(-\w)/g, m => m[1].toUpperCase()); + +export const convertCamelCaseToKebabCase = string => + string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); diff --git a/src/common/utils/utils.spec.js b/src/common/utils/utils.spec.js index 29dfa5d..4e0c9f1 100644 --- a/src/common/utils/utils.spec.js +++ b/src/common/utils/utils.spec.js @@ -1,4 +1,4 @@ -import { compose, convertKebabCaseToCamelCase } from '.'; +import { compose, convertKebabCaseToCamelCase, convertCamelCaseToKebabCase } from '.'; describe('Utils', () => { describe('compose', () => { @@ -25,4 +25,14 @@ describe('Utils', () => { expect(convertKebabCaseToCamelCase('sideNavigation')).toBe('sideNavigation'); }); }); + + describe('converting camel-case to kebab-case', () => { + it('converts camel-case to kebab-case', () => { + expect(convertCamelCaseToKebabCase('sideNavigation')).toBe('side-navigation'); + }); + + it('keeps already kebab-case the same', () => { + expect(convertCamelCaseToKebabCase('side-navigation')).toBe('side-navigation'); + }); + }); }); diff --git a/src/mockComponent/mockComponent.js b/src/mockComponent/mockComponent.js index ed3ee68..6087bb4 100644 --- a/src/mockComponent/mockComponent.js +++ b/src/mockComponent/mockComponent.js @@ -1,3 +1,5 @@ +import angular from 'angular'; + import { compose, convertKebabCaseToCamelCase } from '../common/utils'; export default function mockComponent(kebabCaseName) { @@ -61,7 +63,7 @@ function withProp(mock) { function withSimulate(mock) { mock.simulate = (event, data) => { const callbackName = `on${event[0].toUpperCase()}${event.slice(1)}`; - mock._controller[callbackName]({ $event: data }); + mock._controller[callbackName](data); updateView(); return mock; }; diff --git a/src/mockComponent/mockComponent.spec.js b/src/mockComponent/mockComponent.spec.js index 65ce529..e1527f6 100644 --- a/src/mockComponent/mockComponent.spec.js +++ b/src/mockComponent/mockComponent.spec.js @@ -1,13 +1,13 @@ -import 'angular'; +import angular from 'angular'; import 'angular-mocks'; -import { mockComponent, mount } from '../main'; +import { mockComponent } from '../main'; describe('Mock component', () => { let mockedComponent; beforeEach(() => { angular.module('some-module', []).component('someComponent', { - bindings: { someProp: '<', onSomePropChange: '&', onAnotherPropChange: '&' }, + bindings: { someProp: '<', onSomePropChange: '<' }, }); angular.mock.module('some-module'); @@ -17,7 +17,7 @@ describe('Mock component', () => { describe('exists', () => { it('is true when in mounted template', () => { - mount(` + compile(`
      Something else
      `); @@ -26,13 +26,13 @@ describe('Mock component', () => { }); it('is false when not in template', () => { - mount('
      Something else
      '); + compile('
      Something else
      '); expect(mockedComponent.exists()).toBe(false); }); it('is false when not in mounted template', () => { - mount(` + compile(`
      Something else
      `); @@ -43,13 +43,14 @@ describe('Mock component', () => { describe('props', () => { it('returns props', () => { - const props = { someProp: 'Some value', onSomePropChange: jest.fn() }; - mount( + const onSomePropChange = jest.fn(); + const props = { someProp: 'Some value', onSomePropChange }; + compile( `
      Something else
      `, props, @@ -57,8 +58,7 @@ describe('Mock component', () => { expect(mockedComponent.props()).toEqual({ someProp: 'Some value', - onSomePropChange: expect.any(Function), // due to Angular's handling of callbacks - onAnotherPropChange: expect.any(Function), // due to Angular's handling of callbacks + onSomePropChange, }); }); }); @@ -66,10 +66,10 @@ describe('Mock component', () => { describe('prop', () => { it('returns prop with key', () => { const props = { someProp: 'Some value', onSomePropChange: jest.fn() }; - mount( + compile( ` -
      Something else
      - +
      Something else
      + `, props, ); @@ -79,18 +79,16 @@ describe('Mock component', () => { }); describe('simulate', () => { - let component; let onSomePropChange; beforeEach(() => { onSomePropChange = jest.fn(); const props = { onSomePropChange }; - component = mount( + compile( `
      Something else
      `, @@ -104,19 +102,13 @@ describe('Mock component', () => { expect(onSomePropChange).toBeCalledWith('New value'); }); - it('updates view', () => { - expect(component.find('div').exists()).toBe(false); - mockedComponent.simulate('anotherPropChange'); - expect(component.find('div').exists()).toBe(true); - }); - it('returns itself for chaining', () => { expect(mockedComponent.simulate('somePropChange', 'New value')).toBe(mockedComponent); }); }); it('has template of ', () => { - const component = mount(` + const component = compile(`
      Something else
      @@ -126,3 +118,19 @@ describe('Mock component', () => { expect(component.find('some-component').html()).toBe(''); }); }); + +function compile(template, props) { + let $rootScope; + let element; + + angular.mock.inject($injector => { + $rootScope = $injector.get('$rootScope'); + $rootScope.$ctrl = props; + + const $compile = $injector.get('$compile'); + element = $compile(template)($rootScope); + }); + $rootScope.$digest(); + + return element; +} diff --git a/src/mount/TestElementWrapper/TestElementWrapper.js b/src/mount/TestElementWrapper/TestElementWrapper.js index 439f07c..6434190 100644 --- a/src/mount/TestElementWrapper/TestElementWrapper.js +++ b/src/mount/TestElementWrapper/TestElementWrapper.js @@ -1,3 +1,4 @@ +import angular from 'angular'; import Symbol from 'core-js/library/fn/symbol'; const angularElementSymbol = Symbol('_angularElement'); diff --git a/src/mount/TestElementWrapper/TestElementWrapper.spec.js b/src/mount/TestElementWrapper/TestElementWrapper.spec.js index a807b5b..9a526d5 100644 --- a/src/mount/TestElementWrapper/TestElementWrapper.spec.js +++ b/src/mount/TestElementWrapper/TestElementWrapper.spec.js @@ -1,15 +1,14 @@ -import 'angular'; +import angular from 'angular'; import 'angular-mocks'; import TestElementWrapper from '.'; -import mount from '..'; jest.mock('../../mockComponent', () => jest.fn()); describe('Test element wrapper', () => { describe('length', () => { it('is how many elements are in wrapper', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
      • 1
      • @@ -27,7 +26,7 @@ describe('Test element wrapper', () => { describe('html', () => { it('returns html without container element', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
        • 1
        • @@ -52,7 +51,7 @@ describe('Test element wrapper', () => { describe('text', () => { it('returns text', () => { - const wrapper = mount(` + const wrapper = createWrapper(`

          Some title

          Some text

          @@ -67,7 +66,7 @@ describe('Test element wrapper', () => { describe('hasClass', () => { let button; beforeEach(() => { - const wrapper = mount(` + const wrapper = createWrapper(`
          @@ -87,7 +86,7 @@ describe('Test element wrapper', () => { describe('exists', () => { let wrapper; beforeEach(() => { - wrapper = mount(` + wrapper = createWrapper(`
          @@ -105,7 +104,7 @@ describe('Test element wrapper', () => { describe('find', () => { it('allows finding by any selector', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
          @@ -134,7 +133,7 @@ describe('Test element wrapper', () => { }); it('returns test element wrapper for chaining', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
          Some title
          @@ -147,7 +146,7 @@ describe('Test element wrapper', () => { describe('first', () => { it('returns wrapper for the first element', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
          @@ -161,7 +160,7 @@ describe('Test element wrapper', () => { describe('at', () => { it('returns wrapper for element at index', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
          @@ -174,7 +173,7 @@ describe('Test element wrapper', () => { }); it('returns zero-length wrapper when index is out of range', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
          @@ -189,7 +188,7 @@ describe('Test element wrapper', () => { describe('map', () => { it('maps over elements', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
          • One
          • @@ -207,7 +206,7 @@ describe('Test element wrapper', () => { describe('props', () => { it('returns props', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
            Send money
            @@ -221,7 +220,7 @@ describe('Test element wrapper', () => { describe('prop', () => { it('returns prop with key', () => { - const wrapper = mount(` + const wrapper = createWrapper(`
            Send money
            @@ -238,7 +237,7 @@ describe('Test element wrapper', () => { let onClick; beforeEach(() => { onClick = jest.fn(); - wrapper = mount( + wrapper = createWrapper( `
            @@ -286,7 +285,7 @@ describe('Test element wrapper', () => { describe('setProps', () => { let wrapper; beforeEach(() => { - wrapper = mount( + wrapper = createWrapper( `

            {{ $ctrl.title }}

            @@ -314,6 +313,27 @@ describe('Test element wrapper', () => { }); }); +function createWrapper(template, props) { + const angularElement = compile(template, props); + return new TestElementWrapper(angularElement); +} + +function compile(template, props) { + let $rootScope; + let element; + + angular.mock.inject($injector => { + $rootScope = $injector.get('$rootScope'); + $rootScope.$ctrl = props; + + const $compile = $injector.get('$compile'); + element = $compile(template)($rootScope); + }); + $rootScope.$digest(); + + return element; +} + function trimWhitespace(text) { return text.trim().replace(/\s+/g, ' '); } diff --git a/src/mount/component/component.js b/src/mount/component/component.js new file mode 100644 index 0000000..7edb358 --- /dev/null +++ b/src/mount/component/component.js @@ -0,0 +1,34 @@ +import angular from 'angular'; +import 'angular-mocks'; + +import { convertKebabCaseToCamelCase } from '../../common/utils'; + +export function getPropsDefinition(tag) { + const name = getNameForInjector(tag); + + let propsDefinition; + angular.mock.inject($injector => { + propsDefinition = $injector.get(name)[0].bindToController; + }); + return propsDefinition; +} + +export function compile(template, props) { + let $rootScope; + let element; + + angular.mock.inject($injector => { + $rootScope = $injector.get('$rootScope'); + $rootScope.$ctrl = props; + + const $compile = $injector.get('$compile'); + element = $compile(template)($rootScope); + }); + $rootScope.$digest(); + + return element; +} + +function getNameForInjector(tag) { + return `${convertKebabCaseToCamelCase(tag)}Directive`; +} diff --git a/src/mount/component/component.spec.js b/src/mount/component/component.spec.js new file mode 100644 index 0000000..b4eb55c --- /dev/null +++ b/src/mount/component/component.spec.js @@ -0,0 +1,86 @@ +import { getPropsDefinition, compile } from '.'; + +const mockInjectorGet = jest.fn(); +jest.mock('angular', () => ({ + mock: { + inject: injectable => { + injectable({ + get: mockInjectorGet, + }); + }, + }, +})); +jest.mock('angular-mocks', () => {}); + +describe('Component', () => { + describe('getPropsDefinition', () => { + it('gets props definition for component', () => { + mockInjectorGet.mockImplementation(() => [{ bindToController: {} }]); + + expect(mockInjectorGet).not.toBeCalled(); + getPropsDefinition('a-component'); + expect(mockInjectorGet).toBeCalledWith('aComponentDirective'); + }); + + it('returns props definition', () => { + mockInjectorGet.mockImplementation(() => [ + { bindToController: { prop: '<', anotherProp: '&' } }, + ]); + + const propsDefinition = getPropsDefinition('a-component'); + + expect(propsDefinition).toEqual({ prop: '<', anotherProp: '&' }); + }); + }); + + describe('compile', () => { + let compiledElement; + let $rootScope; + let $compileWithTemplate; + let $compile; + beforeEach(() => { + compiledElement = { angular: 'element' }; + $rootScope = { $digest: jest.fn() }; + $compileWithTemplate = jest.fn(() => compiledElement); + $compile = jest.fn(() => $compileWithTemplate); + mockInjectorGet.mockImplementation(name => { + if (name === '$rootScope') { + return $rootScope; + } + if (name === '$compile') { + return $compile; + } + return null; + }); + }); + + it('compiles template with props under $ctrl property', () => { + expect($compile).not.toBeCalled(); + expect($compileWithTemplate).not.toBeCalled(); + compile('template', { + prop: 'Value', + anotherProp: 'Another value', + }); + expect($compile).toBeCalledWith('template'); + expect($compileWithTemplate).toBeCalledWith({ + $ctrl: { anotherProp: 'Another value', prop: 'Value' }, + $digest: expect.any(Function), + }); + }); + + it('runs digest cycle', () => { + expect($rootScope.$digest).not.toBeCalled(); + compile('template'); + expect($rootScope.$digest).toBeCalled(); + }); + + it('returns compiled element', () => { + const element = compile('template', { + prop: 'Value', + anotherProp: 'Another value', + }); + + expect(element).toBe(compiledElement); + }); + }); +}); diff --git a/src/mount/component/index.js b/src/mount/component/index.js new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/src/mount/component/index.js @@ -0,0 +1 @@ +export * from './component'; diff --git a/src/mount/mount.js b/src/mount/mount.js index 4b4bc98..365b2a1 100644 --- a/src/mount/mount.js +++ b/src/mount/mount.js @@ -1,22 +1,13 @@ +import { validate } from './validation'; +import { createTemplate } from './template'; +import { compile } from './component'; import TestElementWrapper from './TestElementWrapper'; -export default function mount(template, props = {}) { - const angularElement = getAngularElement(template, props); +export default function mount(tag, props = {}) { + validate(tag); - return new TestElementWrapper(angularElement); -} - -function getAngularElement(template, props) { - let $rootScope; - let element; + const template = createTemplate(tag); + const angularElement = compile(template, props); - angular.mock.inject(($compile, $injector) => { - $rootScope = $injector.get('$rootScope'); - $rootScope.$ctrl = props; - - element = $compile(template)($rootScope); - }); - $rootScope.$digest(); - - return element; + return new TestElementWrapper(angularElement); } diff --git a/src/mount/mount.spec.js b/src/mount/mount.spec.js index 79fe4fa..b2488ea 100644 --- a/src/mount/mount.spec.js +++ b/src/mount/mount.spec.js @@ -2,8 +2,14 @@ import 'angular'; import 'angular-mocks'; import mount from '.'; +import { createTemplate } from './template'; +import { compile } from './component'; +import { validate } from './validation'; import TestElementWrapper from './TestElementWrapper'; +jest.mock('./template', () => ({ createTemplate: jest.fn() })); +jest.mock('./component', () => ({ compile: jest.fn() })); +jest.mock('./validation', () => ({ validate: jest.fn() })); jest.mock( './TestElementWrapper', () => @@ -13,44 +19,39 @@ jest.mock( } }, ); - describe('mount', () => { - it('returns a test element wrapper created from angular element', () => { - initAngularComponents({ - someComponent: { - template: '
            Some content
            ', - }, - }); - - const component = mount(''); + afterEach(() => { + jest.clearAllMocks(); + }); - expect(component).toBeInstanceOf(TestElementWrapper); - expect(component.angularElementForSpec).toBeDefined(); + it('validates by tag', () => { + expect(validate).not.toBeCalled(); + mount('a-component'); + expect(validate).toBeCalledWith('a-component'); }); - it('makes props accessible through $ctrl', () => { - initAngularComponents({ - someComponent: { - bindings: { someProp: '<' }, - template: '
            {{ $ctrl.someProp }}
            ', - }, - }); + it('creates template for tag', () => { + expect(createTemplate).not.toBeCalled(); + mount('a-component'); + expect(createTemplate).toBeCalledWith('a-component'); + }); - const { angularElementForSpec } = mount( - '', - { someProp: 'Prop value' }, - ); + it('compiles created template with props', () => { + const props = { prop: 'Value' }; + createTemplate.mockImplementation(tag => `template for ${tag}`); - expect(angularElementForSpec.text()).toBe('Prop value'); + expect(compile).not.toBeCalled(); + mount('a-component', props); + expect(compile).toBeCalledWith('template for a-component', props); }); -}); -function initAngularComponents(components) { - const angularModule = angular.module('someModule', []); + it('returns test element wrapper created from angular element', () => { + const angularElement = { angular: 'element' }; + compile.mockReturnValue(angularElement); - Object.keys(components).forEach(name => { - angularModule.component(name, components[name]); - }); + const component = mount('a-component'); - angular.mock.module('someModule'); -} + expect(component).toBeInstanceOf(TestElementWrapper); + expect(component.angularElementForSpec).toBe(angularElement); + }); +}); diff --git a/src/mount/template/index.js b/src/mount/template/index.js new file mode 100644 index 0000000..1b4a22a --- /dev/null +++ b/src/mount/template/index.js @@ -0,0 +1 @@ +export * from './template'; diff --git a/src/mount/template/template.js b/src/mount/template/template.js new file mode 100644 index 0000000..d91ffb8 --- /dev/null +++ b/src/mount/template/template.js @@ -0,0 +1,18 @@ +import { getPropsDefinition } from '../component'; +import { convertCamelCaseToKebabCase } from '../../common/utils'; + +export function createTemplate(tag) { + const propsDefinition = getPropsDefinition(tag); + const propNames = Object.keys(propsDefinition); + const propsString = getPropsAsString(propNames); + + return `<${tag}${propsString ? ` ${propsString}` : ''}>`; +} + +function getPropsAsString(names) { + return names.map(nameToAttributeAndValueString).join(' '); +} + +function nameToAttributeAndValueString(name) { + return `${convertCamelCaseToKebabCase(name)}="$ctrl.${name}"`; +} diff --git a/src/mount/template/template.spec.js b/src/mount/template/template.spec.js new file mode 100644 index 0000000..ba4d0db --- /dev/null +++ b/src/mount/template/template.spec.js @@ -0,0 +1,36 @@ +import { getPropsDefinition } from '../component'; +import { createTemplate } from '.'; + +jest.mock('../component', () => ({ getPropsDefinition: jest.fn() })); + +describe('Template', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('gets props definition for component', () => { + getPropsDefinition.mockReturnValue({}); + + expect(getPropsDefinition).not.toBeCalled(); + createTemplate('a-component'); + expect(getPropsDefinition).toBeCalledWith('a-component'); + }); + + it('creates template for tag by using prop definition', () => { + getPropsDefinition.mockReturnValue({ prop: '<', anotherProp: '<' }); + + const template = createTemplate('a-component'); + + expect(template).toBe( + '', + ); + }); + + it('creates template for tag without trailing space when no props', () => { + getPropsDefinition.mockReturnValue({}); + + const template = createTemplate('a-component'); + + expect(template).toBe(''); + }); +}); diff --git a/src/mount/validation/AngularjsEnzymeError.js b/src/mount/validation/AngularjsEnzymeError.js new file mode 100644 index 0000000..7792243 --- /dev/null +++ b/src/mount/validation/AngularjsEnzymeError.js @@ -0,0 +1,5 @@ +export default class AngularjsEnzymeError extends Error { + constructor(message) { + super(`AngularJS Enzyme Error: ${message}`); + } +} diff --git a/src/mount/validation/AngularjsEnzymeError.spec.js b/src/mount/validation/AngularjsEnzymeError.spec.js new file mode 100644 index 0000000..ac3013e --- /dev/null +++ b/src/mount/validation/AngularjsEnzymeError.spec.js @@ -0,0 +1,9 @@ +import AngularjsEnzymeError from './AngularjsEnzymeError'; + +describe('AngularJS Enzyme error', () => { + it('prepends error message with "AngularJS Enzyme Error: "', () => { + expect(new AngularjsEnzymeError('Some message').message).toBe( + 'AngularJS Enzyme Error: Some message', + ); + }); +}); diff --git a/src/mount/validation/index.js b/src/mount/validation/index.js new file mode 100644 index 0000000..4d5ffa3 --- /dev/null +++ b/src/mount/validation/index.js @@ -0,0 +1 @@ +export * from './validation'; diff --git a/src/mount/validation/validation.js b/src/mount/validation/validation.js new file mode 100644 index 0000000..5b3a412 --- /dev/null +++ b/src/mount/validation/validation.js @@ -0,0 +1,23 @@ +import { getPropsDefinition } from '../component'; +import AngularjsEnzymeError from './AngularjsEnzymeError'; + +export function validate(tag) { + assertAllPropsAreOneWayBound(tag); +} + +function assertAllPropsAreOneWayBound(tag) { + const notOneWayBoundPropsExist = getNotOneWayBoundPropNames(tag).length > 0; + + if (notOneWayBoundPropsExist) { + throw new AngularjsEnzymeError("All props of component under test must be one-way bound ('<')"); + } +} + +function getNotOneWayBoundPropNames(tag) { + const propsDefinition = getPropsDefinition(tag); + const notOneWayBoundPropNames = Object.entries(propsDefinition) + .filter(([, type]) => type !== '<') + .map(([name]) => name); + + return notOneWayBoundPropNames; +} diff --git a/src/mount/validation/validation.spec.js b/src/mount/validation/validation.spec.js new file mode 100644 index 0000000..9779778 --- /dev/null +++ b/src/mount/validation/validation.spec.js @@ -0,0 +1,23 @@ +import { validate } from '.'; +import { getPropsDefinition } from '../component'; +import AngularjsEnzymeError from './AngularjsEnzymeError'; + +jest.mock('../component', () => ({ getPropsDefinition: jest.fn() })); + +describe('Validation', () => { + it('passes when no one-way bound props exist', () => { + getPropsDefinition.mockReturnValue({ prop: '<', anotherProp: '<' }); + + expect(() => { + validate('a-component'); + }).not.toThrow(); + }); + + it('fails when any not one-way bound prop exists', () => { + getPropsDefinition.mockReturnValue({ prop: '<', anotherProp: '&' }); + + expect(() => { + validate('a-component'); + }).toThrow(AngularjsEnzymeError); + }); +});