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

Please wait… Oh, it didn't work!

Please wait… Oh, it didn't work!

Tobias Bieniek

March 24, 2021
Tweet

More Decks by Tobias Bieniek

Other Decks in Programming

Transcript

  1. A „simple“ application class LikeButton extends Component { @action async

    like() { await fetch('/like', { method: 'POST' }); } }
  2. A „simple“ application class LikeButton extends Component { @action async

    like() { await fetch('/like', { method: 'POST' }); } } No Loading State No Error Handling
  3. Loading State <button type="button" disabled={{this.inProgress}} {{on "click" this.like}} > {{#if

    this.inProgress}} Please wait… {{else}} Like 👍 {{/if}} !</button>
  4. Loading State class LikeButton extends Component { @tracked inProgress =

    false; @action async like() { this.inProgress = true; await fetch('/like', { method: 'POST' }); this.inProgress = false; } }
  5. Loading State class LikeButton extends Component { @tracked inProgress =

    false; @action async like() { this.inProgress = true; await fetch('/like', { method: 'POST' }); this.inProgress = false; } } What happens when fetch() fails?
  6. Loading State class LikeButton extends Component { @tracked inProgress =

    false; @action async like() { this.inProgress = true; try { await fetch('/like', { method: 'POST' }); } finally { this.inProgress = false; } } }
  7. Loading State class LikeButton extends Component { @tracked inProgress =

    false; @action async like() { this.inProgress = true; try { await fetch('/like', { method: 'POST' }); } finally { this.inProgress = false; } } } What if the component is already gone?
  8. Loading State class LikeButton extends Component { @tracked inProgress =

    false; @action async like() { this.inProgress = true; try { await fetch('/like', { method: 'POST' }); } finally { if (!this.isDestroying !&& !this.isDestroyed) { this.inProgress = false; } } } }
  9. Loading State with ember-concurrency class LikeButton extends Component { @task

    *likeTask() { yield fetch('/like', { method: 'POST' }); } get inProgress() { return this.likeTask.isRunning; } }
  10. Loading State with ember-concurrency class LikeButton extends Component { @task

    *likeTask() { yield fetch('/like', { method: 'POST' }); } get inProgress() { return this.likeTask.isRunning; } }
  11. Happy Path Tests module('Component | LikeButton', function(hooks) { setupRenderingTest(hooks); test('happy

    path', function() { await render(hbs`<LikeButton!/>`); await click('button'); assert.dom('[data-test-like-counter]') .hasText('42 Likes'); }); });
  12. Happy Path Tests module('Component | LikeButton', function(hooks) { setupRenderingTest(hooks); test('happy

    path', function() { await render(hbs`<LikeButton!/>`); await click('button'); assert.dom('[data-test-like-counter]') .hasText('42 Likes'); }); }); Causes real API requests…
  13. API mocking • Pretender github.com/pretenderjs/pretender • Mirage miragejs.com / ember-cli-mirage.com

    • Polly.js netflix.github.io/pollyjs emberobserver.com/categories/mocking,-fixtures,-and-factories
  14. API mocking import { setupMirage } from 'ember-cli-mirage/test-support'; module('Component |

    LikeButton', function(hooks) { setupRenderingTest(hooks); setupMirage(hooks); test('happy path', function() { this.server.post('/like', { likes: 5 }); await render(hbs`<LikeButton!/>`); await click('button'); assert.dom('[data-test-like-counter]') .hasText('5 Likes'); }); });
  15. Loading State Tests test('loading state', function() { this.server.post('/like', { likes:

    5 }, { timing: 500 }); await render(hbs`<LikeButton!/>`); await click('button'); assert.dom('button') .isDisabled() .hasText('Please wait…'); });
  16. Loading State Tests test('loading state', function() { this.server.post('/like', { likes:

    5 }, { timing: 500 }); await render(hbs`<LikeButton!/>`); await click('button'); assert.dom('button') .isDisabled() .hasText('Please wait…'); }); Assertion failed 😢
  17. Loading State Tests test('loading state', function() { this.server.post('/like', { likes:

    5 }, { timing: 500 }); await render(hbs`<LikeButton!/>`); await click('button'); await waitFor('button[disabled]'); assert.dom('button') .isDisabled() .hasText('Please wait…'); await settled(); assert.dom('button') .isEnabled() .hasText('Like 👍'); }); github.com/emberjs/ember-test-helpers/blob/master/API.md#waitfor
  18. Loading State Tests test('loading state', function() { this.server.post('/like', { likes:

    5 }, { timing: 500 }); await render(hbs`<LikeButton!/>`); await click('button'); await waitFor('button[disabled]'); assert.dom('button') .isDisabled() .hasText('Please wait…'); await settled(); assert.dom('button') .isEnabled() .hasText('Like 👍'); }); github.com/emberjs/ember-test-helpers/blob/master/API.md#waitfor Tests are getting slooooow… 🥱
  19. Loading State Tests import { defer } from 'rsvp'; test('loading

    state', function() { let deferred = defer(); this.server.post('/like', deferred.promise); await render(hbs`<LikeButton!/>`); click('button'); await waitFor('button[disabled]'); assert.dom('button') .isDisabled() .hasText('Please wait…'); deferred.resolve({ likes: 5 }); await settled(); assert.dom('button') .isEnabled() .hasText('Like 👍'); }); github.com/tildeio/rsvp.js/#deferred
  20. Summary Loading State Tests • Use a library / addon

    to mock your API calls (Mirage, Pretender, Polly.js, ...) • Use await waitFor() after a regular test helper without await (click, focus, ...) to wait for a loading state • Use await settled() to check the end state • Use defer() to avoid race conditions and speed up the tests
  21. Error Handling Tests test('500 Internal Server Error', function() { this.server.post('/like',

    {}, 500); await click('button'); await render(hbs`<LikeButton!/>`); assert.dom('[data-test-error]') .hasText('whoops… that did not work!'); });
  22. Error Handling Tests test('500 Internal Server Error', function() { this.server.post('/like',

    {}, 500); await click('button'); await render(hbs`<LikeButton!/>`); assert.dom('[data-test-error]') .hasText('whoops… that did not work!'); }); Where do we display this?
  23. Notifications with ember-cli-notifications class LikeButton extends Component { @service notifications;

    @task *likeTask() { let response = yield fetch('/like', { method: 'POST' }); if (!response.ok) { this.notifications.error( 'whoops… that did not work!' ); } } }
  24. Notifications with ember-cli-notifications let ENV = { !// !!... 'ember-cli-notifications':

    { autoClear: true, }, }; if (environment !!=== 'test') { !// disable auto clearing so that we can !// manually clear the queue if needed ENV['ember-cli-notifications'].autoClear = false; } config/environment.js
  25. fetch error scenarios class LikeButton extends Component { @service notifications;

    @task *likeTask() { let response = yield fetch('/like', { method: 'POST' }); if (!response.ok) { this.notifications.error( 'whoops… that did not work!' ); } } } fetch can fail too!
  26. fetch error scenarios class LikeButton extends Component { @service notifications;

    @task *likeTask() { try { let response = yield fetch('/like', { method: 'POST' }); if (!response.ok) { throw new Error('HTTP request failed'); } } catch { this.notifications.error('whoops… that did not work!'); } } }
  27. Error Handling Tests test('Network Error', function() { window.fetch = async

    function () { throw new TypeError( 'NetworkError when attempting to fetch resource.' ); }; await render(hbs`<LikeButton!/>`); await click('button'); assert.dom('[data-test-notification-message="error"]') .hasText('whoops… that did not work!'); });
  28. Error Handling Tests test('Network Error', function() { window.fetch = async

    function () { throw new TypeError( 'NetworkError when attempting to fetch resource.' ); }; await render(hbs`<LikeButton!/>`); await click('button'); assert.dom('[data-test-notification-message="error"]') .hasText('whoops… that did not work!'); }); needs to be reset after the test!
  29. Error Handling Tests function setupFetchRestore(hooks) { let oldFetch; hooks.beforeEach(function ()

    { oldFetch = window.fetch; }); hooks.afterEach(function () { window.fetch = oldFetch; }); }
  30. Error Reporting import * as Sentry from '@sentry/browser'; class LikeButton

    extends Component { @task *likeTask() { try { let response = yield fetch('/like', { method: 'POST' }); if (!response.ok) { throw new Error('HTTP request failed'); } } catch (error) { this.notifications.error('whoops… that did not work!'); Sentry.captureException(error); } } }
  31. Error Reporting import * as Sentry from '@sentry/browser'; class LikeButton

    extends Component { @task *likeTask() { try { let response = yield fetch('/like', { method: 'POST' }); if (!response.ok) { throw new Error('HTTP request failed'); } } catch (error) { this.notifications.error('whoops… that did not work!'); Sentry.captureException(error); } } } do we want to send ALL errors to Sentry?
  32. Error Types • Network Error expected • HTTP 5xx Server

    Errors expected • HTTP 4xx Client Errors mostly unexpected • JSON Errors unexpected • Other Errors unexpected
  33. Error Types • Network Error expected • HTTP 5xx Server

    Errors expected • HTTP 4xx Client Errors mostly unexpected • JSON Errors unexpected • Other Errors unexpected ✅ ❌ ✅ ❌ ✅ ( ✅ ) ✅ ✅ ✅ ✅ Notification Error Reporting
  34. Error Reporting class LikeButton extends Component { @task *likeTask() {

    try { let response = yield fetch('/like', { method: 'POST' }); if (!response.ok) { throw new Error('HTTP request failed'); } } catch (error) { this.notifications.error('whoops… that did not work!'); if (!isNetworkError(error) !&& !isServerError(error)) { Sentry.captureException(error); } } } }
  35. Error Reporting class LikeButton extends Component { @task *likeTask() {

    try { let response = yield fetch('/like', { method: 'POST' }); if (!response.ok) { throw new Error('HTTP request failed'); } } catch (error) { this.notifications.error('whoops… that did not work!'); if (!isNetworkError(error) !&& !isServerError(error)) { Sentry.captureException(error); } } } } but how?
  36. Error Reporting class LikeButton extends Component { @task *likeTask() {

    try { let response = yield fetch('/like', { method: 'POST' }); if (!response.ok) { throw new HttpError(response); } } catch (error) { this.notifications.error('whoops… that did not work!'); if (!isNetworkError(error) !&& !isServerError(error)) { Sentry.captureException(error); } } } }
  37. Error Reporting export class HttpError extends Error { constructor(response) {

    let message = `HTTP request failed with: ${response.status} ${response.statusText}`; super(message); this.status = response.status; } } function isServerError(error) { return error instanceof HttpError !&& error.status !>= 500; }
  38. Summary Error Handling • Use a notification system • Use

    an error reporting service • Throw HttpError for HTTP error responses • Only send unexpected errors to your error reporting service