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!

Avatar for Tobias Bieniek

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