Save 37% off PRO during our Black Friday Sale! »

Say More

Say More

A talk on UI Testing from EmberConf 2018

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

Bbbcbbdc9e73179d5e86a6244459ec4f?s=128

Jamie White

March 13, 2018
Tweet

Transcript

  1. Say More

  2. None
  3. Techniques

  4. Techniques Deep dive

  5. Techniques Deep dive Grand vision

  6. Techniques Deep dive Grand vision What if?

  7. UI Testing

  8. None
  9. Access via accessibility

  10. None
  11. “ 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.”
  12. A less powerful language

  13. visit(); click(); fillIn(); find();

  14. 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'); });
  15. 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
  16. fillIn();

  17. 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'); });
  18. 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'); });
  19. fill_in 'Title', with: 'Example' fill_in 'Body', with: 'This is my

    issue…'
  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('button[type="submit"]'); assert.dom('h1').hasText('Example'); });
  21. Acceptance | say more: filing an issue ✘ Promise rejected

    during "filing an issue": Could not find a form control labelled "Body"
  22. <div> <textarea id="body" name="body"></textarea> </div>

  23. <div> <label for="body">Body</label> <textarea id="body" name="body"></textarea> </div>

  24. Acceptance | say more: filing an issue ✔ Element h1

    has text "Example"
  25. import { fillIn } from '@ember/test-helpers';

  26. import fillIn from 'say-more/tests/helpers/fill-in';

  27. import { fillIn } from '@ember/test-helpers'; export default function fillInByLabel(label,

    value) { let control = findControlForLabel(label); return fillIn(control, value); }
  28. 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}"`); }
  29. import { findAll } from '@ember/test-helpers'; function findLabel(text) { return

    findAll('label').find(label => label.innerText.includes(text)); }
  30. 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(',')); }
  31. 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'); });
  32. click();

  33. 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'); });
  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. Acceptance | say more: filing an issue ✘ Promise rejected

    during "filing an issue": Could not find a button containing "Submit"
  36. <button type="submit"> <img src="/images/submit.gif"> </button>

  37. <button type="submit"> Submit </button>

  38. Acceptance | say more: filing an issue ✔ Element h1

    has text "Example"
  39. 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); }
  40. 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; }
  41. function matches(text) { return element => matchesInnerText(element, text) || matchesTitle(element,

    text) || matchesAriaLabel(element, text); }
  42. 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); }
  43. 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'); });
  44. Getting from here to there

  45. 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'); });
  46. 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"
  47. <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>
  48. <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>
  49. <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>
  50. <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>
  51. Acceptance | say more: filing an issue ✔ Element h1

    has text "Example"
  52. 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}"`); }
  53. 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}"`); }
  54. 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; }
  55. When is a click not a click?

  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'); });
  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'); // <-- Press return assert.dom('h1').hasText('Example'); });
  58. Acceptance | say more: filing an issue (with keyboard) ✘

    Element h1 exists
  59. <form> <div> <label for="title">Title</label> <input id="title" name="title" type="text"> </div> .

    . . <div> <button onclick={{action submit}}>Submit</button> </div> </form>
  60. <form onsubmit={{action submit}}> <div> <label for="title">Title</label> <input id="title" name="title" type="text">

    </div> . . . <div> <button type="submit">Submit</button> </div> </form>
  61. Acceptance | say more: filing an issue (with keyboard) ✔

    Element h1 has text "Example"
  62. 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); }
  63. 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'); }
  64. 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'); });
  65. 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'); });
  66. None
  67. 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'); });
  68. How many requests?

  69. 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'); });
  70. 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'); });
  71. 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
  72. 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()
  73. 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() ^^^^^^^^^^
  74. 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()
  75. 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
  76. "relationships": { "labels": { "links": { "related": "/issues/1/labels" } }

    }
  77. // mirage/serializers/issue.js import ApplicationSerializer from './application'; export default ApplicationSerializer.extend({ include:

    [], links(issue) { return { labels: { related: `/issues/${issue.id}/labels` } }; } });
  78. // mirage/serializers/issue.js import ApplicationSerializer from './application'; export default ApplicationSerializer.extend({ include:

    ['labels'], links(issue) { return { labels: { related: `/issues/${issue.id}/labels` } }; } });
  79. Acceptance | say more: filing an issue ✔ Element h1

    has text "Example"
  80. 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); }
  81. export default function clickByLabel(text) { . . . return click(element);

    }
  82. export default async function clickByLabel(text) { . . . return

    click(element); }
  83. export default async function clickByLabel(text) { . . . await

    click(element); }
  84. import { trackRequests } from './mirage'; export default async function

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

    clickByLabel(text) { . . . let requests = await trackRequests(() => click(element)); }
  86. 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); } }
  87. export async function trackRequests(block) { let { handledRequests } =

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

    if (request.requestBody) { result += ` with ${request.requestBody}`; } return result; }
  89. 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
  90. In conclusion

  91. 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'); });
  92. 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
  93. 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'); });
  94. 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
  95. 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
  96. 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
  97. 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
  98. None
  99. ember-a11y-testing

  100. ember-a11y-testing ember-cli-page-object

  101. ember-a11y-testing ember-cli-page-object ember-test-selectors

  102. None
  103. Say More