Skip to content
This repository has been archived by the owner on Jan 13, 2025. It is now read-only.

chore(infrastructure): Better error messages in verifyDefaultAdapter #3516

Merged
merged 12 commits into from
Sep 7, 2018
146 changes: 117 additions & 29 deletions test/unit/helpers/foundation.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,44 +24,132 @@
import {assert} from 'chai';
import td from 'testdouble';

// Sanity tests to ensure the following:
// - Default adapters contain functions
// - All expected adapter functions are accounted for
// - Invoking any of the default methods does not throw an error.
// Every foundation test suite include this verification.
export function verifyDefaultAdapter(FoundationClass, expectedMethods) {
/**
* Sanity tests to ensure the following:
* - Default adapters contain functions
* - All expected adapter functions are accounted for
* - Invoking any of the default methods does not throw an error.
* Every foundation test suite include this verification.
* @param {!F.} FoundationClass
* @param {!Array<string>} expectedMethodNames
* @template F
*/
export function verifyDefaultAdapter(FoundationClass, expectedMethodNames) {
const {defaultAdapter} = FoundationClass;
const methods = Object.keys(defaultAdapter).filter((k) => typeof defaultAdapter[k] === 'function');
const adapterKeys = Object.keys(defaultAdapter);
const actualMethodNames = adapterKeys.filter((key) => typeof defaultAdapter[key] === 'function');

assert.equal(actualMethodNames.length, adapterKeys.length, 'Every adapter key must be a function');

assert.equal(methods.length, Object.keys(defaultAdapter).length, 'Every adapter key must be a function');
// Test for equality without requiring that the array be in a specific order
assert.deepEqual(methods.slice().sort(), expectedMethods.slice().sort());
const actualArray = actualMethodNames.slice().sort();
const expectedArray = expectedMethodNames.slice().sort();
assert.deepEqual(actualArray, expectedArray, getUnequalArrayMessage(actualArray, expectedArray));

// Test default methods
methods.forEach((m) => assert.doesNotThrow(defaultAdapter[m]));
actualMethodNames.forEach((m) => assert.doesNotThrow(defaultAdapter[m]));
}

// Returns an object that intercepts calls to an adapter method used to register event handlers, and adds
// it to that object where the key is the event name and the value is the function being used. This is the
// preferred way of testing interaction handlers.
//
// ```javascript
// test('#init adds a click listener which adds a "foo" class', (t) => {
// const {foundation, mockAdapter} = setupTest();
// const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler');
// foundation.init();
// handlers.click(/* you can pass event info in here */ {type: 'click'});
// t.doesNotThrow(() => td.verify(mockAdapter.addClass('foo')));
// t.end();
// });
// ```
//
// Note that `handlerCaptureMethod` _must_ have a signature of `(string, EventListener) => any` in order to
// be effective.
export function captureHandlers(adapter, handlerCaptureMethod) {
/**
* Returns an object that intercepts calls to an adapter method used to register event handlers, and adds
* it to that object where the key is the event name and the value is the function being used. This is the
* preferred way of testing interaction handlers.
*
* ```javascript
* test('#init adds a click listener which adds a "foo" class', (t) => {
* const {foundation, mockAdapter} = setupTest();
* const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler');
* foundation.init();
* handlers.click(/* you can pass event info in here *\/ {type: 'click'});
* t.doesNotThrow(() => td.verify(mockAdapter.addClass('foo')));
* t.end();
* });
* ```
*
* Note that `handlerCaptureMethodName` _must_ have a signature of `(string, EventListener) => any` in order to
* be effective.
*
* @param {!A} adapter
* @param {string} handlerCaptureMethodName
* @template A
*/
export function captureHandlers(adapter, handlerCaptureMethodName) {
const {isA} = td.matchers;
const handlers = {};
td.when(adapter[handlerCaptureMethod](isA(String), isA(Function))).thenDo((type, handler) => {
td.when(adapter[handlerCaptureMethodName](isA(String), isA(Function))).thenDo((type, handler) => {
handlers[type] = (evtInfo = {}) => handler(Object.assign({type}, evtInfo));
});
return handlers;
}

/**
* @param {!Array<string>} actualArray
* @param {!Array<string>} expectedArray
* @return {string}
*/
function getUnequalArrayMessage(actualArray, expectedArray) {
/**
* @param {!Array<string>} values
* @param {string} singularName
* @return {string}
*/
const format = (values, singularName) => {
const count = values.length;
if (count === 0) {
return '';
}
const plural = count === 1 ? '' : 's';
const str = values.join(', ');
return `${count} ${singularName}${plural}: ${str}`;
};

/**
* @param {!Set<string>} actualSet
* @param {!Set<string>} expectedSet
*/
const getAddedStr = (actualSet, expectedSet) => {
const addedArray = [];
actualSet.forEach((val) => {
if (!expectedSet.has(val)) {
addedArray.push(val);
}
});
return format(addedArray, 'unexpected method');
};

/**
* @param {!Set<string>} actualSet
* @param {!Set<string>} expectedSet
*/
const getRemovedStr = (actualSet, expectedSet) => {
const removedArray = [];
expectedSet.forEach((val) => {
if (!actualSet.has(val)) {
removedArray.push(val);
}
});
return format(removedArray, 'missing method');
};

/**
* @param {!Array<string>} array
* @return {!Set<string>}
*/
const toSet = (array) => {
const set = new Set();
array.forEach((value) => set.add(value));
return set;
};

const actualSet = toSet(actualArray);
const expectedSet = toSet(expectedArray);
const addedStr = getAddedStr(actualSet, expectedSet);
const removedStr = getRemovedStr(actualSet, expectedSet);
const messages = [addedStr, removedStr].filter((val) => Boolean(val));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(val) => val.length > 0 ? Makes it a little more clear this is just filtering out empty strings...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


if (messages.length === 0) {
return '';
}

return `Found ${messages.join('; ')}`;
}
18 changes: 16 additions & 2 deletions test/unit/helpers/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,23 @@

import td from 'testdouble';

// Returns a foundation configured to use a mock object with the same api as a default adapter,
// as well as that adapter itself.
/**
* Returns a foundation configured to use a mock object with the same API as a default adapter,
* as well as that adapter itself.
* The trailing `.` in the `@param` type below is intentional: It indicates a reference to the class itself instead of
* an instance of the class.
* See https://youtrack.jetbrains.com/issue/WEB-10214#focus=streamItem-27-1305930-0-0
* @param {!MDCFoundation.} FoundationClass
* @return {{mockAdapter: !Object, foundation: !MDCFoundation}}
*/
export function setupFoundationTest(FoundationClass) {
// Make code coverage happy by instantiating the class with no arguments.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused... where were we missing coverage due to this? Judging by this comment it should currently be impossible for us to have 100% branch coverage on foundations, but that's clearly not the case...

Copy link
Contributor Author

@acdvorak acdvorak Sep 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All I know is that Dialog code coverage was stuck at 13/14 branches until I added this line. 🤷‍♂️

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this line:

image

File   Statements   Branches   Functions   Lines  
constants.js   100% 3/3 100% 0/0 100% 0/0 100% 3/3
foundation.js   100% 64/64 92.86% 13/14 100% 22/22 100% 61/61
index.js   100% 46/46 100% 6/6 100% 31/31 100% 45/45
util.js   100% 6/6 100% 2/2 100% 4/4 100% 5/5

With this line:

image

File   Statements   Branches   Functions   Lines  
constants.js   100% 3/3 100% 0/0 100% 0/0 100% 3/3
foundation.js   100% 64/64 100% 14/14 100% 22/22 100% 61/61
index.js   100% 46/46 100% 6/6 100% 31/31 100% 45/45
util.js   100% 6/6 100% 2/2 100% 4/4 100% 5/5

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just ran coverage with/without this line and it only affects branch coverage in dialog, line-ripple, and list. I'll see if I can figure out why, but I am inclined to leave it out of this PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured out why 3 specific components have diffs with this line. It's because for some reason those components are using a default parameter in their foundation constructors, whereas all other foundations use an Object.assign within the constructor instead. I'm not sure what prompted this deviation, as the Object.assign pattern existed much earlier.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#3522 removes the default parameters from line-ripple and list.

// Babel transpiles optional function arguments into `if` statements. Istanbul (our code coverage tool) then reports
// the transpiled `else` branch as lacking coverage, but the coverage report UI doesn't tell you where the
// missing branches are. See https://github.com/gotwarlost/istanbul/issues/582#issuecomment-334683612
// eslint-disable-next-line no-new
new FoundationClass();

const mockAdapter = td.object(FoundationClass.defaultAdapter);
const foundation = new FoundationClass(mockAdapter);
return {mockAdapter, foundation};
Expand Down
3 changes: 2 additions & 1 deletion test/unit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@

/** @fileoverview Bootstraps the test bundle for karma-webpack. */

const testsContext = require.context('.', true, /\.test\.js$/);
// https://github.com/webpack/docs/wiki/context#requirecontext
const testsContext = require.context(/* directory */ '.', /* useSubdirectories */ true, /\.test\.js$/);
testsContext.keys().forEach(testsContext);