Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Say More

Say More

A talk on UI Testing from EmberConf 2018

See the code here:
https://github.com/jgwhite/say-more

Jamie White

March 13, 2018
Tweet

More Decks by Jamie White

Other Decks in Technology

Transcript

  1. “ https://www.w3.org/2001/tag/doc/leastPower.html Tim Berners-Lee & Noah Mendelsohn
 The Rule of

    Least Power Expressing constraints, relationships and processing instructions in less powerful languages increases the flexibility with which information can be reused: the less powerful the language, the more you can do with the data stored in that language.”
  2. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('input[name="title"]',

    'Example'); await fillIn('textarea[name="body"]', 'This is my issue…'); await fillIn('select[name="assignees"]', 'Alice'); await fillIn('select[name="labels"]', 'Feature'); await fillIn('select[name="project"]', 'Some Project'); await fillIn('select[name="milestone"]', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); });
  3. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('input[name="title"]',

    'Example'); await fillIn('textarea[name="body"]', 'This is my issue…'); await fillIn('select[name="assignees"]', 'Alice'); await fillIn('select[name="labels"]', 'Feature'); await fillIn('select[name="project"]', 'Some Project'); await fillIn('select[name="milestone"]', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); }); Access via accessibility A less powerful language Say more
  4. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('input[name="title"]',

    'Example'); await fillIn('textarea[name="body"]', 'This is my issue…'); await fillIn('select[name="assignees"]', 'Alice'); await fillIn('select[name="labels"]', 'Feature'); await fillIn('select[name="project"]', 'Some Project'); await fillIn('select[name="milestone"]', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); });
  5. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); });
  6. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); });
  7. Acceptance | say more: filing an issue ✘ Promise rejected

    during "filing an issue": Could not find a form control labelled "Body"
  8. import { fillIn } from '@ember/test-helpers'; export default function fillInByLabel(label,

    value) { let control = findControlForLabel(label); return fillIn(control, value); }
  9. function findControlForLabel(text) { let label = findLabel(text); if (label &&

    label.control) { return label.control; } let control = findControl(text); if (control) { return control; } if (label && !label.control) { throw new Error(`Found the label "${label.innerText}" but no associated form control`); } throw new Error(`Could not find a form control labelled "${text}"`); }
  10. import { findAll } from '@ember/test-helpers'; function findLabel(text) { return

    findAll('label').find(label => label.innerText.includes(text)); }
  11. import { find } from '@ember/test-helpers'; function findControl(text) { let

    selectors = []; for (let tag of ['input', 'textarea', 'select']) { for (let attr of ['title', 'aria-label', 'placeholder']) { selectors.push(`${tag}[${attr}="${text}"]`); } } return find(selectors.join(',')); }
  12. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); });
  13. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); });
  14. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); });
  15. Acceptance | say more: filing an issue ✘ Promise rejected

    during "filing an issue": Could not find a button containing "Submit"
  16. import { click } from '@ember/test-helpers'; export default function clickByLabel(text)

    { let element = findElement(text); if (!element) { throw new Error(`Could not find a button containing "${text}"`); } return click(element); }
  17. import { findAll } from '@ember/test-helpers'; function findElement(text) { let

    selector = 'button,a[href],[role="button"]'; let element = findAll(selector).find(matches(text)); return element; }
  18. function matchesInnerText(element, text) { return element.innerText.includes(text); } function matchesTitle(element, text)

    { return element.title && element.title.includes(text); } function matchesAriaLabel(element, text) { let ariaLabel = element.getAttribute('aria-label'); return ariaLabel && ariaLabel.includes(text); }
  19. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); });
  20. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); });
  21. Acceptance | say more: filing an issue ✘ Promise rejected

    during "filing an issue": The user would have to tab backwards to reach the button containing "Submit"
  22. <div> <button type="submit">Submit</button> </div> <form> <div> <label for="title">Title</label> <input id="title"

    name="title" type="text"> </div> <div> <label for="body">Body</label> <textarea id="body" name="body"></textarea> </div> <div> <label for="assignees">Assignees</label> <select id="assignees" name="assignees" multiple> <option>Alice</option>
  23. <div> <button type="submit">Submit</button> </div> <form> <div> <label for="title">Title</label> <input id="title"

    name="title" type="text"> </div> <div> <label for="body">Body</label> <textarea id="body" name="body"></textarea> </div> <div> <label for="assignees">Assignees</label> <select id="assignees" name="assignees" multiple> <option>Alice</option>
  24. <option>Some Other Project</option> </select> </div> <div> <label for="milestone">Milestone</label> <select id="milestone"

    name="milestone"> <option value="">No milestone</option> <option>v1</option> <option>v2</option> </select> </div> </form> <div> <button type="submit">Submit</button> </div>
  25. <option>Some Other Project</option> </select> </div> <div> <label for="milestone">Milestone</label> <select id="milestone"

    name="milestone"> <option value="">No milestone</option> <option>v1</option> <option>v2</option> </select> </div> </form> <div> <button type="submit">Submit</button> </div>
  26. let tabs = calculateTabsTo(element); if (tabs < 0) { throw

    new Error(`The user would have to tab backwards to reach the button containing "${text}"`); } return click(element); } import { calculateTabsTo } from './tabbability'; export default function clickByLabel(text) { let element = findElement(text); if (!element) { throw new Error(`Could not find a button containing "${text}"`); }
  27. let tabs = calculateTabsTo(element); if (tabs < 0) { throw

    new Error(`The user would have to tab backwards to reach the button containing "${text}"`); } return click(element); } import { calculateTabsTo } from './tabbability'; export default function clickByLabel(text) { let element = findElement(text); if (!element) { throw new Error(`Could not find a button containing "${text}"`); }
  28. import { findAll } from '@ember/test-helpers'; export function calculateTabsTo(targetElement) {

    let { activeElement } = document; // TODO: Make this less naive let tabbables = findAll('*').filter(e => e.tabIndex >= 0); let activeIndex = tabbables.indexOf(activeElement); let targetIndex = tabbables.indexOf(targetElement); if (targetIndex === -1) { throw new Error('The target element is not tabbable, \ try setting tabindex'); } return targetIndex - activeIndex; }
  29. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); });
  30. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); // <-- Press return assert.dom('h1').hasText('Example'); });
  31. <form> <div> <label for="title">Title</label> <input id="title" name="title" type="text"> </div> .

    . . <div> <button onclick={{action submit}}>Submit</button> </div> </form>
  32. import { focus, triggerKeyEvent } from '@ember/test-helpers'; import findButton from

    './find-button'; import { calculateTabsTo } from './tabbability'; const KEY_RETURN = 13; export default async function keyboardClick(text) { let element = findButton(text); if (!element) { throw new Error(`Could not find a button containing "${text}"`); } let tabs = calculateTabsTo(element); if (tabs < 0) { throw new Error(`The user would have to tab backwards to reach the button containing "${text}"`); } await focus(element); await triggerKeyEvent(element, 'keydown', KEY_RETURN); await triggerKeyEvent(element, 'keypress', KEY_RETURN); await triggerKeyEvent(element, 'keyup', KEY_RETURN); }
  33. await focus(element); await triggerKeyEvent(element, 'keydown', KEY_RETURN); await triggerKeyEvent(element, 'keypress', KEY_RETURN);

    await triggerKeyEvent(element, 'keyup', KEY_RETURN); if (element.type === 'submit' && element.form) { await triggerEvent(element.form, 'submit'); }
  34. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); });
  35. test('filing an issue (with keyboard)', async function(assert) { await visit('/issues/new');

    await fillIn('Title', 'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await keyboardClick('Submit'); assert.dom('h1').hasText('Example'); });
  36. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); });
  37. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); });
  38. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); // <-- What’s the network up to? assert.dom('h1').hasText('Example'); });
  39. Acceptance | say more: filing an issue ✘ Promise rejected

    during "filing an issue": The application made 3 requests after clicking "Submit" ==> POST /issues with { "data": { ... } } ==> GET /issues/1/labels ==> GET /issues/1?include=labels
  40. createIssue: task(function * (attrs) { let store = this.get('store'); attrs.labels

    = attrs.labels .map(id => store.peekRecord('label', id)); let issue = store.createRecord('issue', attrs); yield issue.save(); this.transitionToRoute('issues.issue', issue.get('id')); }).drop()
  41. createIssue: task(function * (attrs) { let store = this.get('store'); attrs.labels

    = attrs.labels .map(id => store.peekRecord('label', id)); let issue = store.createRecord('issue', attrs); yield issue.save(); this.transitionToRoute('issues.issue', issue.get('id')); }).drop() ^^^^^^^^^^
  42. createIssue: task(function * (attrs) { let store = this.get('store'); attrs.labels

    = attrs.labels .map(id => store.peekRecord('label', id)); let issue = store.createRecord('issue', attrs); yield issue.save(); this.transitionToRoute('issues.issue', issue); }).drop()
  43. Acceptance | say more: filing an issue ✘ Promise rejected

    during "filing an issue": The application made 2 requests after clicking "Submit" ==> POST /issues with { "data": { ... } } ==> GET /issues/1/labels
  44. // mirage/serializers/issue.js import ApplicationSerializer from './application'; export default ApplicationSerializer.extend({ include:

    ['labels'], links(issue) { return { labels: { related: `/issues/${issue.id}/labels` } }; } });
  45. export default function clickByLabel(text) { let element = findElement(text); if

    (!element) { throw new Error(`Could not find a button containing "${text}"`); } let tabs = calculateTabsTo(element); if (tabs < 0) { throw new Error(`The user would have to tab backwards to reach the button containing "${text}"`); } return click(element); }
  46. import { trackRequests } from './mirage'; export default async function

    clickByLabel(text) { . . . await click(element); }
  47. import { trackRequests } from './mirage'; export default async function

    clickByLabel(text) { . . . let requests = await trackRequests(() => click(element)); }
  48. import { trackRequests, inspectRequests } from './mirage'; export default async

    function clickByLabel(text) { . . . let requests = await trackRequests(() => click(element)); if (requests.length > 1) { let msg = `The application made ${requests.length} requests after clicking "${text}"`; msg += '\n'; msg += inspectRequests(requests); throw new Error(msg); } }
  49. export async function trackRequests(block) { let { handledRequests } =

    server.pretender; let n = handledRequests.length; await block(); let result = handledRequests.slice(n); return result; }
  50. export function inspectRequest(request) { let result = `==> ${request.method} ${request.url}`;

    if (request.requestBody) { result += ` with ${request.requestBody}`; } return result; }
  51. Acceptance | say more: filing an issue ✘ Promise rejected

    during "filing an issue": The application made 3 requests after clicking "Submit" ==> POST /issues with { "data": { ... } } ==> GET /issues/1/labels ==> GET /issues/1?include=labels
  52. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('input[name="title"]',

    'Example'); await fillIn('textarea[name="body"]', 'This is my issue…'); await fillIn('select[name="assignees"]', 'Alice'); await fillIn('select[name="labels"]', 'Feature'); await fillIn('select[name="project"]', 'Some Project'); await fillIn('select[name="milestone"]', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); });
  53. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('input[name="title"]',

    'Example'); await fillIn('textarea[name="body"]', 'This is my issue…'); await fillIn('select[name="assignees"]', 'Alice'); await fillIn('select[name="labels"]', 'Feature'); await fillIn('select[name="project"]', 'Some Project'); await fillIn('select[name="milestone"]', 'v1'); await click('button[type="submit"]'); assert.dom('h1').hasText('Example'); }); Access via accessibility A less powerful language Say more
  54. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); });
  55. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); }); ✅ Semantically labelled
  56. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); }); ✅ Semantically labelled ✅ Tabbable
  57. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); }); ✅ Semantically labelled ✅ Tabbable ✅ Supports keyboard interaction
  58. test('filing an issue', async function(assert) { await visit('/issues/new'); await fillIn('Title',

    'Example'); await fillIn('Body', 'This is my issue…'); await fillIn('Assignees', 'Alice'); await fillIn('Labels', 'Feature'); await fillIn('Project', 'Some Project'); await fillIn('Milestone', 'v1'); await click('Submit'); assert.dom('h1').hasText('Example'); }); ✅ Semantically labelled ✅ Tabbable ✅ Supports keyboard interaction ✅ Uses the network sparingly