12 Testing

This chapter covers

Now that we’ve covered most of the features of Svelte, it’s time to turn our focus to testing. We want to feel confident that our applications have properly implemented their intended functionality in a way that is accessible to a variety of users and that will remain that way through repeated updates. Three primary kinds of tests to consider are unit, end-to-end, and accessibility.

Unit tests are intended to test a piece of code in isolation from the rest of an application. These are great for testing individual functions and components. We will cover implementing unit tests using the Jest testing framework.

End-to-end tests are intended to test an application in the same way that a user would interact with it. This includes logging in (if the app requires it), navigating to each page within the app (if there are multiple pages), and generally putting the app into each of the states a user would encounter. We will cover implementing end-to-end tests using the Cypress testing framework.

Accessibility tests verify that users with special needs will be able to successfully use the application. Often they focus on supporting users with vision impairments that require more color contrast or the use of screen readers. We will cover implementing accessibility tests using the Svelte compiler, Lighthouse, axe, and WAVE.

Storybook is a tool that catalogs and demonstrates individual components that are used by an app. It works with many web frameworks, including Svelte.

Proper use of the tools described in this chapter can help developers identify issues in their Svelte and Sapper applications. These tools can also help reduce the issues users might encounter when apps are released for production use.

This chapter is a bit longer than previous ones due to the many code examples and screenshots. But it’s well worth working through them all to get better at testing Svelte apps, which results in better apps overall!

12.1 Unit tests with Jest

Jest (https://jestjs.io) bills itself as “a delightful JavaScript Testing Framework with a focus on simplicity.” It can be used to implement unit tests for many web frameworks, including Svelte.

By default, Jest tests are run in jsdom (https://github.com/jsdom/jsdom), which supports headless execution of DOM-based tests. This enables us to run Jest tests from a command prompt and in continuous integration (CI) environments.

Jest can watch source and test files and automatically rerun the tests if any of these files are modified. This is a convenient feature when debugging test failures.

Jest supports snapshot tests. These are simple tests that assert that some output should be the same as it was the last time the test was run. They make implementing many tests much easier, but they require extra diligence in examining test failures.

Jest test suites are defined by functions that are passed to the describe function . These functions can

Typically each test suite is defined in a separate file with an extension of .spec.js. These files can be colocated with the .js and .svelte source files they test, or they can be placed in the _ _tests_ _ directory at the same level as the src directory.

Note Recall that Svelte bundles only include code that is actually used. This means that test code will not be included.

The functions passed to the test function can

For details on using the expect function and “matchers” that specify what to expect, see the Jest documentation for “Using Matchers” (https://jestjs.io/docs/en/using-matchers). Many examples are provided in this section.

The Svelte Testing Library (https://testing-library.com/docs/svelte-testing-library/ intro) can be used in conjunction with Jest to make implementing tests of Svelte components easier. It is built on the DOM Testing Library (https://testing-library.com/ docs/dom-testing-library/intro), which provides functions for implementing tests that are not specific to any web framework.

The most important functions provided by the Svelte Testing Library are render and fireEvent.

The render function takes a Svelte component and an object containing props to pass to the component. It returns an object containing a container property and query functions that can be called to find nodes in the DOM created by the component. The container property value is the topmost DOM element of the rendered component. Tests can find DOM nodes inside this using the DOM methods querySelector and querySelectorAll.

For details on the query functions returned by the render function, see the DOM Testing Library’s “Queries” documentation (https://testing-library.com/docs/dom-testing-library/api-queries). Some of the most commonly used query functions are getByText, getByLabelText, and getByTestId.

The fireEvent function fires a given event on a specific DOM node. This can trigger a Svelte component to update the DOM. Calls to the expect function that follow can verify that the expected DOM updates were made.

To install everything needed to implement unit tests for Svelte components, cd to the project directory and enter npm install -D name, where name is each of the following:

Babel compiles modern JavaScript code to JavaScript that runs in current browsers. Configure the use of Babel by creating the following file in the top project directory.

Listing 12.1 Babel configuration in babel.config.js

module.exports = {
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current'       
        }
      }
    ]
  ]
};

This setting avoids the error message “regenerator-runtime not found.”

Configure the use of Jest by creating the following file in the top project directory.

Listing 12.2 Jest configuration in jest.config.js

module.exports = {
  bail: false,                              
  moduleFileExtensions: ['js', 'svelte'],
  transform: {
    '^.+\\.js$': 'babel-jest',
    '^.+\\.svelte$': 'svelte-jester'
  },
  verbose: true                             
};

This tells Jest not to exit a test suite when one of its tests fails, which allows you to get results for other tests in the suite.

This causes Jest to show the result of each test rather than just a summary of the results for each test suite.

Add the following npm scripts in package.json.

"test": "jest src",
"test:watch": "npm run test -- --watch"

To run the unit tests once, enter npm test. To run the unit tests in watch mode so they are automatically rerun when code changes are detected, enter npm run test:watch.

12.1.1 Unit tests for the Todo app

Here is a Jest test suite for the Todo component shown at the end of chapter 2.

Listing 12.3 Jest test for Todo component in src/Todo.spec.js

import {cleanup, render} from '@testing-library/svelte';
 
import Todo from './Todo.svelte';
 
describe('Todo', () => {
  const text = 'buy milk';
  const todo = {text};

  afterEach(cleanup);                 
 
  test('should render', () => {
    const {getByText} = render(Todo, {props: {todo}});
    const checkbox = document.querySelector('input[type="checkbox"]');
    expect(checkbox).not.toBeNull(); // found checkbox
    expect(getByText(text)); // found todo text
    expect(getByText('Delete')); // found Delete button
  });
});

This unmounts any components mounted in the previously run test.

There is no easy way to test that events are fired when the checkbox state is changed or when the Delete button is clicked. These are covered by tests for the TodoList component.

The following listing shows a Jest test suite for the TodoList component from the end of chapter 2.

Listing 12.4 Jest test for TodoList component in src/TodoList.spec.js

import {cleanup, fireEvent, render, waitFor} from '@testing-library/svelte';

import TodoList from './TodoList.svelte';

describe('TodoList', () => {
  const PREDEFINED_TODOS = 2;                                
 
  afterEach(cleanup);
 
  function expectTodoCount(count) {                          
    return waitFor(() => {                                   
      const lis = document.querySelectorAll('li');           
      expect(lis.length).toBe(count);
    });
  }
 
  test('should render', async () => {
    const {getByText} = render(TodoList);
    expect(getByText('To Do List'));
    expect(getByText('1 of 2 remaining'));
    expect(getByText('Archive Completed')); // button
    await expectTodoCount(PREDEFINED_TODOS);
  });
 
  test('should add a todo', async () => {
    const {getByTestId, getByText} = render(TodoList);
 
    const input = getByTestId('todo-input');                 
    const value = 'buy milk';
    fireEvent.input(input, {target: {value}});
    fireEvent.click(getByText('Add'));
 
    await expectTodoCount(PREDEFINED_TODOS + 1);
    expect(getByText(value));
  });

  test('should archive completed', async () => {
    const {getByText} = render(TodoList);
    fireEvent.click(getByText('Archive Completed'));
    await expectTodoCount(PREDEFINED_TODOS - 1);
    expect(getByText('1 of 1 remaining'));
  });
 
  test('should delete a todo', async () => {
    const {getAllByText, getByText} = render(TodoList);
    const text = 'learn Svelte';                            
    expect(getByText(text));
 
    const deleteBtns = getAllByText('Delete');
    fireEvent.click(deleteBtns[0]);                         
    await expectTodoCount(PREDEFINED_TODOS - 1);
  });
 
  test('should toggle a todo', async () => {
    const {container, getByText} = render(TodoList);
    const checkboxes = container.querySelectorAll('input[type="checkbox"]');
 
    await fireEvent.click(checkboxes[1]);                   
    expect(getByText('0 of 2 remaining'));
 
    await fireEvent.click(checkboxes[0]);                   
    expect(getByText('1 of 2 remaining'));
  });
});

This is the number of todos that are automatically added in the TodoList component.

This is used by many of the test functions below.

This waits for the DOM to be updated; it returns a Promise.

Each Todo component has an li root element.

This requires adding the following attribute to the input element in src/TodoList.svelte: data-testid="todo-input":.

This is the text in the first todo.

This deletes the first todo.

This toggles the second todo.

This toggles the first todo.

12.1.2 Unit tests for the Travel Packing app

Let’s add some unit tests in the Travel Packing app. The finished code can be found at http://mng.bz/QyNG.

Change the button element in src/Item.svelte to include a data-testid attribute to make it easier to find this button in a test.

Listing 12.5 Change to Item component in src/Item.svelte

<button class="icon" data-testid="delete"
  on:click={() => dispatch('delete')}>
  &#x1F5D1;
</button>

The following listing shows a Jest test suite for the Item component.

Listing 12.6 Jest test for Item component in Item.spec.js

import {cleanup, render} from '@testing-library/svelte';
 
import Item from './Item.svelte';
 
describe('Item', () => {
  const categoryId = 1;
  const dnd = {};                                                       
  const item = {id: 2, name: 'socks', packed: false};
 
  afterEach(cleanup);
 
  test('should render', () => {
    const {getByTestId, getByText} = render(Item, {categoryId, dnd, item});
    const checkbox = document.querySelector('input[type="checkbox"]');
    expect(checkbox).not.toBeNull();                                    
    expect(getByText(item.name));                                       
    expect(getByTestId('delete'));                                       
  });
});

The Item component requires this prop, but it won’t be used in the test, so an empty object suffices.

Tests that the checkbox can be found

Tests that the item name can be found

Tests that the Delete button can be found

The preceding test simply verifies that the Item component renders the expected DOM elements. This can also be accomplished using a “snapshot” test. The first time snapshot tests are run, a _ _snapshots_ _ directory is created, and files containing rendered output are saved in this directory. In subsequent runs the rendered output is compared to these files. Any differences are reported as failed tests.

Relying on snapshot tests requires attention to detail on the part of the developers who run them. When tests are run the first time, developers should verify that components are currently rendering correctly. In subsequent test runs, if any tests fail, developers should carefully examine the differences reported and determine whether they are expected based on recent code changes. If they are expected, press the “U” key to update all the snapshot files that differ. Otherwise, fix the errors and rerun the tests.

The following listing shows a snapshot test for the Item component. Note how much simpler this is to write than the previous “should render” test.

Listing 12.7 Snapshot test for Item component in Item.spec.js

test('should match snapshot', () => {
  const {container} = render(Item, {categoryId, dnd, item});
  expect(container).toMatchSnapshot();
});

Tests for the Category component need to be able to find the input element that binds to the variable itemName. To simplify this, change the input element to include a data-testid attribute.

Listing 12.8 Adding data-testid attribute to input in src/Category.svelte

<input data-testid="item-input" required bind:value={itemName} />

The following listing shows a Jest test suite for the Category component.

Listing 12.9 Jest tests for Category component in Category.spec.js

import {cleanup, fireEvent, render, waitFor} from '@testing-library/svelte';

import Category from './Category.svelte';

describe('Category', () => {
  let itemCount = 0;                                           

  const category = {id: 1, name: 'Clothes', items: {}};
  const categories = [category];
  const dnd = {};                                              
  const props = {categories, category, dnd, show: 'all'};

  beforeEach(() => {
    category.items = {
      1: {id: 1, name: 'socks', packed: true},
      2: {id: 2, name: 'shoes', packed: false}
    };
    itemCount = Object.keys(category.items).length;
  });

  afterEach(cleanup);

  test('should match snapshot', () => {
    const {container} = render(Category, props);
    expect(container).toMatchSnapshot();
  });

  function expectItemCount(count) {
    return waitFor(() => {
      const lis = document.querySelectorAll('li');             
      expect(lis.length).toBe(count);
    });
  }

  test('should render', async () => {
    const {getByText} = render(Category, props);
    expect(getByText('Clothes'));
    expect(getByText('1 of 2 remaining'));
    expect(getByText('New Item'));
    expect(getByText('Add Item'));
    await expectItemCount(itemCount);
  });

  test('should add an item', async () => {
    const {getByTestId, getByText} = render(Category, props);

    const input = getByTestId('item-input');
    const value = 't-shirts';
    fireEvent.input(input, {target: {value}});
    fireEvent.click(getByText('Add Item'));

    await expectItemCount(itemCount + 1);
    expect(getByText(value));
  });

  test('should delete an item', async () => {
    const {getAllByTestId} = render(Category, props);
 
    const deleteBtns = getAllByTestId('delete');
    fireEvent.click(deleteBtns[0]); // deletes first item
    await expectItemCount(itemCount - 1);
  });
 
  test('should toggle an item', async () => {
    const {container, getByText} = render(Category, props);
 
    const checkboxes = container.querySelectorAll('input[type="checkbox"]');
    expect(checkboxes.length).toBe(2);
 
    const [shoesCheckbox, socksCheckbox] = checkboxes;         
 
    expect(socksCheckbox.nextElementSibling.textContent).toBe('socks');
    await fireEvent.click(socksCheckbox);
    expect(getByText('2 of 2 remaining'));                     

    expect(shoesCheckbox.nextElementSibling.textContent).toBe('shoes');
    await fireEvent.click(shoesCheckbox);
    expect(getByText('1 of 2 remaining'));                     
  });
});

This will be set to the number of items that are added to the category inside the function that is passed to beforeEach.

The Category component requires this prop, but it won’t be used in the test, so an empty object suffices.

Each Item component has an li root element.

The items are sorted so that “shoes” comes before “socks”.

Now nothing in this category is packed.

Now one item in this category is packed.

When the Jest tests for the Travel Packing app are run, DialogPolyfill, which is used in the custom Dialog component, will not be defined. To account for this, modify the call to onMount in src/Dialog.svelte to match the following:

DListing 12.10 ialog component onMount in src/Dialog.svelte

onMount(() => {
  if (dialogPolyfill) dialogPolyfill.registerDialog(dialog);
});

You might be tempted to wrap an if around the call to onMount instead of placing the if inside the function passed to onMount. However, that would not be correct because lifecycle functions cannot be called conditionally.

To temporarily skip running certain test suites, change their describe function to describe.skip. To temporarily skip running certain tests within a suite, change their test function to test.skip. To temporarily run only certain tests in a suite, change their test function to test.only.

We are now ready to run the Jest tests using the npm scripts added earlier in package.json.

When tests fail, output similar to the following will appear in the terminal where the tests ran. This test failure is caused by searching for “SOCKS” instead of “socks”.

Listing 12.11 Failed Jest output

FAIL  src/Item.spec.js
  Item
    ✘ should render (32ms)
    ✔ should match snapshot (4ms)
 
  ● Item › should render
 
    Unable to find an element with the text: SOCKS.
    This could be because the text is broken up by multiple elements.
    In this case, you can provide a function for your
    text matcher to make your matcher more flexible.

    <body>
      <div>
        <li
          class="svelte-ft3yg2"
          draggable="true"
        >
          <input
            class="svelte-ft3yg2"
            type="checkbox"
          />
 
          <span
            class="packed-false svelte-ft3yg2"
          >
            socks
          </span>
 
          <button
            class="icon svelte-ft3yg2"
            data-testid="delete"
          >
            🗑
          </button>
        </li>
      </div>
    </body>
 
      15 |     const checkbox = document.querySelector('input[type="checkbox"]');
      16 |     expect(checkbox).not.toBeNull(); // found checkbox
    > 17 |     expect(getByText(item.name.toUpperCase())); // found item name
         |            ^
      18 |     expect(getByTestId('delete')); // found delete button
      19 |   });
      20 |
 
      at getElementError (node_modules/@testing-library/dom/dist/query-helpers.js:22:10)
      at node_modules/@testing-library/dom/dist/query-helpers.js:76:13
      at getByText (node_modules/@testing-library/dom/dist/query-helpers.js:59:17)
      at Object.<anonymous> (src/Item.spec.js:17:12)

Test Suites: 1 failed, 1 passed, 2 total
Tests:       1 failed, 6 passed, 7 total
Snapshots:   2 passed, 2 total
Time:        1.964s, estimated 2s
Ran all test suites matching /src/i.
 
Watch Usage: Press w to show more.

When tests are successful, output similar to the following will appear in the terminal where the tests ran.

Listing 12.12 Successful Jest output

PASS  src/Item.spec.js
  Item
    ✔ should render (22ms)
    ✔ should match snapshot (5ms)
 
 PASS  src/Category.spec.js
  Category
    ✔ should match snapshot (48ms)
    ✔ should render (16ms)
    ✔ should add an item (14ms)
    ✔ should delete an item (32ms)
    ✔ should toggle an item (11ms)
 
Test Suites: 2 passed, 2 total
Tests:       7 passed, 7 total
Snapshots:   2 passed, 2 total
Time:        1.928s, estimated 2s
Ran all test suites matching /src/i.

Watch Usage: Press w to show more.

12.2 End-to-end tests with Cypress

Cypress (www.cypress.io/) bills itself as “fast, easy and reliable testing for anything that runs in a browser.” It supports end-to-end testing of web applications written using any framework (including Svelte) or even no framework. This means that the tests can exercise the functionality of a web application without focusing on specific components as is done in unit tests.

All the functionality of Cypress is accessed through the global variable cy. Queries to find elements on the page can search by text and CSS selectors. By default they wait 4 seconds for elements to appear.

Suppose a web application renders a button containing the text “Press Me” that when clicked renders the text “Success”. The following code finds this button, clicks it, and verifies that “Success” is found.

cy.get('button').contains('Press Me').click();
cy.contains('Success');

The following code finds an input and types text into it. It assumes that the input has a data-testid attribute that can be used in tests to find it.

cy.get('[data-testid=some-id]').type('some text');

Cypress tests benefit greatly from you writing and using utility functions that navigate to various application states and make assertions. For example, a function that navigates to the login page, enters a username and password, and clicks the Login button is useful in nearly all tests of a web app that requires logging in. You will see several examples of these kinds of functions shortly, in tests for the Travel Packing app.

To install Cypress in a Svelte app, enter npm install -D cypress. Then add the following npm scripts in package.json:

"cy:open": "cypress open",     
"cy:run": "cypress run",       

This runs Cypress in interactive mode.

This runs Cypress in command-line mode.

To launch the Cypress test tool in interactive mode, enter npm run cy:open. The tests are automatically rerun if any source files or test files are modified. This command also creates a cypress directory, if it doesn’t already exist, containing the following subdirectories:

These subdirectories are populated with sample files, all of which can be deleted. Create your test files under the cypress/integration directory with extensions of .spec.js.

12.2.1 End-to-end tests for the Todo app

Here are some end-to-end tests for the Todo application shown at the end of chapter 2.

Listing 12.13 Cypress tests for the TodoList component in cypress/integration/TodoList.spec.js

const baseUrl = 'http://localhost:5000/';
 
describe('Todo app', () => {
  it('should add todo', () => {
    cy.visit(baseUrl);
    cy.contains('1 of 2 remaining');
    cy.contains('Add')                                   
      .as('addBtn')                                      
      .should('be.disabled');
 
    const todoText = 'buy milk';                         
    cy.get('[data-testid=todo-input]')
      .as('todoInput')
      .type(todoText);
 
    const addBtn = cy.get('@addBtn');
    addBtn.should('not.be.disabled');
    addBtn.click();
 
    cy.get('@todoInput').should('have.value', '');
    cy.get('@addBtn').should('be.disabled');
    cy.contains(todoText);
    cy.contains('2 of 3 remaining');
  });
 
  it('should toggle done', () => {
    cy.visit(baseUrl);
    cy.contains('1 of 2 remaining');
 
    cy.get('input[type=checkbox]')                       
      .first()                                           
      .as('cb1')                                         
      .click();                                          
    cy.contains('2 of 2 remaining');                     
 
    cy.get('@cb1').check();                              
    cy.contains('1 of 2 remaining');                     
  });
 
  it('should delete todo', () => {
    cy.visit(baseUrl);
    cy.contains('1 of 2 remaining');
 
    const todoText = 'learn Svelte'; // first todo
    cy.contains('ul', todoText);

    cy.contains('Delete').click();                      
    cy.contains('ul', todoText).should('not.exist');    
    cy.contains('1 of 1 remaining');                    
  });                                                   
 
  it('should archive completed', () => {
    cy.visit(baseUrl);
 
    const todoText = 'learn Svelte'; // first todo
    cy.contains('ul', todoText);
 
    cy.contains('Archive Completed').click();           
    cy.contains('ul', todoText).should('not.exist');    
    cy.contains('1 of 1 remaining');                    
  });
});

The Add button should be disabled until text is entered.

This saves a reference to found elements for later use.

This enters todo text.

This finds the first checkbox and toggles it.

This toggles the same checkbox again to show that status text returns to its previous value.

This clicks the first Delete button.

This clicks the Archive Completed button.

To run the tests in interactive mode, start the local server with npm run dev, enter npm run cy:open, and click the Run All Specs button in the upper-right corner of the Cypress tool. This opens a browser window where all the tests are run. When you’re finished running the tests, close this browser window and the Cypress tool.

To aid in debugging, add console.log calls in the application code. When there are several of these in the code, it is helpful if they identify the source file and function where they are located. For example, console.log('TodoList.svelte toggleDone: todo =', todo);. To see their output, open the DevTools Console in the browser window where the tests are running.

To run the tests in command-line mode, start the local server with npm run dev, open another terminal, and enter npm run cy:run. This outputs test results in the terminal window, records an MP4 video of the test run, and outputs the file path of the video, which is in the cypress/videos subdirectory. Double-click the video file to watch it.

12.2.2 End-to-end tests for the Travel Packing app

Let’s add end-to-end tests in the Travel Packing app. The finished code can be found at http://mng.bz/eQqQ.

  1. Install Cypress by entering npm install -D cypress.

  2. Add the npm scripts suggested for Cypress in section 12.2.

  3. Start the app by entering npm run dev.

  4. In another terminal, enter npm run cy:open to create the cypress directory and starting files inside it.

  5. Move the cypress/integration/examples directory to cypress/examples so these example tests don’t appear in the Cypress app but can be referred to later for examples.

  6. Add a data-testid attribute to the input element that binds to the categoryName variable in src/Checklist.svelte so the input element is easy to find in a test:

    <input
      data-testid="category-name-input"
      required
      bind:value={categoryName}
    />
  7. Create a travel-packing.spec.js file in the cypress/integration directory containing the code in listing 12.14.

Listing 12.14 Cypress tests for the Travel Packing app in cypress/integration/travel-packing.spec.js

const baseUrl = 'http://localhost:5000/';
 
function login() {
  cy.visit(baseUrl);
  cy.contains('Username')
    .children('input')
    .type('username');
  cy.contains('Password')
    .children('input')
    .type('password');
  cy.get('button')
    .contains('Login')
    .click();
}
 
function addCategories() {
  login();                                              
 
  cy.get('[data-testid=category-name-input]')
    .as('nameInput')
    .type('Clothes');
  cy.get('button')
    .contains('Add Category')
    .click();
 
  cy.get('@nameInput').type('Toiletries{enter}');       
}
 
function addItems() {
  addCategories();                                      
 
  cy.get('[data-testid=item-input]')
    .first()                                            
    .as('item-input-1')
    .type('socks');
  cy.get('button')
    .contains('Add Item')
    .first()
    .click();
  cy.get('@item-input-1').type('shoes{enter}');
  verifyStatus('Clothes', '2 of 2 remaining');
 
  cy.get('[data-testid=item-input]')
    .last()                                             
    .type('razor{enter}');
  verifyStatus('Toiletries', '1 of 1 remaining');
}
 
function deleteCategory(categoryName) {
  cy.contains(new RegExp(`^${categoryName}$`))
    .siblings('button')
    .click();
}
 
function deleteItem(itemName) {
  cy.contains(new RegExp(`^${itemName}$`))
    .siblings('button')
    .click();
}
 
function togglePacked(itemName) {
  cy.contains(new RegExp(`^${itemName}$`))
    .siblings('input[type="checkbox"]')
    .click();
}
 
function verifyStatus(categoryName, expectedStatus) {
  cy.contains(new RegExp(`^${categoryName}$`))
    // This is useful to verify that the correct element is found.
    // It draws a red outline around all the matching elements.
    //.then(el => el.css('outline', 'solid red'))
    .siblings('span')
    .contains(expectedStatus);
}
 
describe('Travel Packing app', () => {
  it('should login', login);
 
  it('should add categories', addCategories);
 
  it('should add items', addItems);
 
  it('should toggle packed', () => {
    addItems();
    verifyStatus('Clothes', '2 of 2 remaining');
 
    togglePacked('shoes');
    verifyStatus('Clothes', '1 of 2 remaining');
 
    togglePacked('shoes');
    verifyStatus('Clothes', '2 of 2 remaining');
  });

  it('should delete item', () => {
    addItems();
    verifyStatus('Clothes', '2 of 2 remaining');
 
    deleteItem('shoes');
    verifyStatus('Clothes', '1 of 1 remaining');
  });
 
  it('should delete category', () => {
    addItems();
    verifyStatus('Toiletries', '1 of 1 remaining');
 
    deleteItem('razor');                                   
    verifyStatus('Toiletries', '0 of 0 remaining');
 
    const categoryName = 'Toiletries';
    // Verify that the category exists.
    cy.get('.categories h2 > span').contains(categoryName);
    deleteCategory(categoryName);
    // Verify that the category no longer exists.
    cy.get('.categories h2 > span')
      .contains(categoryName)
      .should('not.exist');
  });
 
  it('should logout', () => {
    login();
    cy.get('button')
      .contains('Log Out')
      .click();
    cy.contains('Login');
  });
});

We must log in before adding categories.

Including “{enter}” in a string passed to the type method simulates a user pressing the Enter key.

We must add categories before adding items.

This finds the input item in the “Clothes” category.

This finds the input item in the “Toiletries” category.

We must delete all items in a category before the category can be deleted.

To run these end-to-end tests, enter npm run cy:open. This renders the results shown in figure 12.1. Note the green check marks next to the successful tests.

Figure 12.1 Cypress in Chrome

To run these end-to-end tests in command-line mode, enter npm run cy:run. This outputs listing 12.15 in the terminal window.

Listing 12.15 Cypress command-line output

(Run Starting)
 
  ┌────────────────────────────────────────────────────────────────────────────┐
  │ Cypress:    3.8.1                                                          │
  │ Browser:    Electron 78 (headless)                                         │
  │ Specs:      1 found (travel-packing.spec.js)                               │
  └────────────────────────────────────────────────────────────────────────────┘
 
───────────────────────────────────────────────────────────────────────────────
 
  Running:  travel-packing.spec.js                                      (1 of 1)
 
  Travel Packing app
    ✓ should login (888ms)
    ✓ should add categories (1196ms)
    ✓ should add items (1846ms)
    ✓ should toggle packed (2057ms)
    ✓ should delete item (1973ms)
    ✓ should delete category (2037ms)
    ✓ should logout (1938ms)
 
  7 passing (13s)
 
  (Results)
 
  ┌────────────────────────────────────────────────────────────────────────────┐
  │ Tests:        7                                                            │
  │ Passing:      7                                                            │
  │ Failing:      0                                                            │
  │ Pending:      0                                                            │
  │ Skipped:      0                                                            │
  │ Screenshots:  0                                                            │
  │ Video:        true                                                         │
  │ Duration:     12 seconds                                                   │
  │ Spec Ran:     travel-packing.spec.js                                       │
  └────────────────────────────────────────────────────────────────────────────┘
 
  (Video)
 
  ●  Started processing:  Compressing to 32 CRF
● Finished processing: /Users/mark/Documents/programming/languages/ (0 seconds)
javascript/svelte/book/svelte-and-sapper-in-action/ travel-packing-ch11/cypress/videos/ travel-packing.spec.js.mp4 ================================================================================ (Run Finished) Spec Tests Passing Failing Pending Skipped ┌────────────────────────────────────────────────────────────────────────────┐ │ ✓ travel-packing.spec.js 00:12 7 7 - - - │ └────────────────────────────────────────────────────────────────────────────┘ ✓ All specs passed! 00:12 7 7 - - -

12.3 Accessibility tests

There are many tools for detecting web UI accessibility issues, such as the following.

These tools can identify different issues, so it is recommended that you use more than one. As you will see below, sometimes it is not desirable to address all the identified issues.

12.3.1 Svelte compiler

The Svelte compiler detects some accessibility issues and outputs warning messages that begin with “A11y:”. You will see a list of the messages, along with their codes, in table 12.1. Note that some codes are used for more than one message.

Table 12.1 Svelte accessibility warning codes and messages

Code

Message

a11y-distracting-elements

Avoid <{name}> elements

a11y-structure

<figcaption> must be an immediate child of <figure>

a11y-structure

<figcaption> must be first or last child of <figure>

a11y-aria-attributes

<{name}> should not have aria-* attributes

a11y-unknown-aria-attribute

Unknown aria attribute 'aria-{type}'

a11y-hidden

<{name}> element should not be hidden

a11y-misplaced-role

<{name}> should not have role attribute

a11y-unknown-role

Unknown role '{value}'

a11y-accesskey

Avoid using accesskey

a11y-autofocus

Avoid using autofocus

a11y-misplaced-scope

The scope attribute should only be used with <th> elements

a11y-positive-tabindex

Avoid tabindex values above zero

a11y-invalid-attribute

'{value}' is not a valid {attribute-name} attribute

a11y-missing-attribute

<a> element should have an href attribute

a11y-missing-content

<{name}> element should have child content

a11y-missing-attribute

<{name}> element should have {article} {sequence} attribute

In some cases it may be desirable to suppress certain warnings. To do this, add a special comment above the offending lines. For example, to suppress the warning “Avoid using autofocus,” add the following line before all input elements that use it:

12.3.2 Lighthouse

The easiest way to use Lighthouse to identify accessibility issues is as follows:

  1. Open the website to be tested in Chrome.

  2. Open the DevTools.

  3. Click the Audits tab (see figure 12.2).

  4. Verify that the Accessibility checkbox is checked.

  5. Click the Generate Report button.

  6. To keep current test results and open a new tab for the next test, press the “+” in the upper-left corner of the Audits tab.

  7. Visit another page or state of the site.

  8. Click the Generate Report button again.

  9. Repeat steps 6 to 8 for each page or state of the site, noting the reported accessibility issues for each.

Figure 12.2 Lighthouse setup in Chrome DevTools

Note The issues have already been fixed in this chapter’s version of the Travel Packing app. To see these issues, run Lighthouse on the chapter 11 version.

To rerun tests after code changes, click the Clear All icon (a circle with a diagonal line) and then click the Run Audits button.

Lighthouse reported two issues for the Login page. The color contrast on the Login and Sign Up buttons, gray on white, is insufficient. These can be fixed by changing gray to black in public/global.css.

Lighthouse only reported one issue for the Checklist page of the Travel Packing app after adding a category and an item in it. It was “Background and foreground colors do not have a sufficient contrast ratio,” as shown in figures 12.3 and 12.4. The color contrast between the white text and the orange and cornflowerblue backgrounds is not sufficient from an accessibility standpoint. These color contrast issues can be fixed by choosing a color other than orange for .hero in src/App.svelte and choosing a color other than cornflowerblue for main in src/App.svelte and header in src/Dialog.svelte.

Figure 12.3 Lighthouse results in Chrome DevTools (top of page)

Figure 12.4 Lighthouse results in Chrome DevTools (bottom of page)

One way to discover acceptable colors is to use the Contrast Checker tool at https:// webaim.org/resources/contrastchecker/. Current colors can be entered, and a Lightness slider can be moved until the contrast ratio reaches at least 4.5.

To fix the contrast issues reported by Lighthouse, change all uses of the color cornflowerblue (#6495ed) to #3f6fde and all uses of the color orange (#ffa500) to #a3660a. A good way to do this is to assign the new colors to CSS variables and refer to the variables wherever the colors are needed. We can define global CSS variables in src/App.svelte using the :root syntax, as follows:

:root {
  --heading-bg-color: #a3660a;
  --primary-color: #3f6fde;
  ...
}

Then we can refer to the CSS variables wherever needed, as follows:

background-color: var(--heading-bg-color);
background-color: var(--primary-color);

12.3.3 axe

To install axe in Chrome, follow these steps:

  1. Browse to https://www.deque.com/axe/.

  2. Click the Download free Chrome extension button.

  3. Click the Add to Chrome button.

To run axe on a website, follow these steps:

  1. Browse to the website to be tested.

  2. Open the browser’s DevTools.

  3. Click the Axe tab.

  4. Click the Analyze button.

  5. Click each issue identified in the left nav to see a detailed description on the right.

To navigate between multiple instances of the same issue type, click the “<” and “>” buttons in the upper-right. To see the rendered element associated with the issue, click Highlight. To see the DOM element associated with the issue, click “</> Inspect Node”. To rerun the tests after code changes, click Run Again.

When axe is run on the Checklist page of the Travel Packing app after adding a category and an item in it, you’ll see the results shown in figures 12.5, 12.6, and 12.7.

Figure 12.5 axe results

Figure 12.6 axe detail for an issue

Figure 12.7 axe fix suggestion for an issue

Let’s walk through the issues.

12.3.4 WAVE

To install WAVE in Chrome, follow these steps:

  1. Browse to https://wave.webaim.org/.

  2. Click the Browser Extensions link.

  3. Click the link for the extension for your browser.

  4. Click the Add to Chrome button.

To run WAVE on a website, follow these steps:

  1. Browse to the website to be tested.

  2. Click the WAVE icon () to the right of the browser’s address bar.

  3. An accessibility report will open on the left side of the page.

  4. Click the View Details button.

WAVE reports both issues and the good things it finds (such as having alt text for images). Click an icon for an issue to scroll to it on the web page.

WAVE reported the issues shown in figures 12.8 and 12.9 for the Checklist page of the Travel Packing app after adding a category and an item in it.

Let’s walk through the issues.

One fix for the last two issues is to edit Checklist.svelte and use a fieldset element to surround the radio buttons. We want the radio buttons to be laid out in a row. But there is a browser issue where flexbox layout is not honored on fieldset elements.

 

Figure 12.8 WAVE summary

Figure 12.9 WAVE detail

A workaround is to wrap all the fieldset children in a div and apply flexbox layout to that. Here is a replacement for the <div class="radios"> element that fixes the accessibility issues.

<fieldset>
  <div>
    <legend>Show</legend>
    <label>
      <input name="show" type="radio" value="all" bind:group={show} />
      All
    </label>
    <label>
      <input name="show" type="radio" value="packed" bind:group={show} />
      Packed
    </label>
    <label>
      <input name="show" type="radio" value="unpacked" bind:group={show} />
      Unpacked
    </label>
    <button class="clear" on:click={clearAllChecks}>Clear All Checks</button>
  </div>
</fieldset>

Replace the CSS rules for .radios with the following:

fieldset {
  border: none;
  margin: 0;
  padding: 0;
}
 
fieldset > div {
  display: flex;
  align-items: center;
}
 
fieldset input {
  margin-left: 1.5rem;
}
 
fieldset legend {
  padding: 0;
}

12.4 Component demos and debugging with Storybook

Storybook (https://storybook.js.org) bills itself as “an open source tool for developing UI components in isolation.” It works with many web frameworks including React, Vue, Angular, Svelte, and more.

Storybook displays a list of components in specific states in its left nav (see figure 12.10). Selecting a component or state renders it in the main area of the UI. It also allows interaction with the component in all ways supported by the component. For example, if a Category component from the Travel Packing app is rendered, it can be renamed, and items can be added, renamed, marked as packed, and deleted.

Storybook has many uses. One is for showcasing the set of components that have been developed. Another is for testing and debugging components separately from web apps that use them. This is much faster than having to navigate to the use of a particular component in a web app.

Detailed instructions for using Storybook with Svelte components can be found in the Storybook documentation at https://storybook.js.org/docs/guides/guide-svelte/.

Figure 12.10 Storybook before adding components

To add the use of Storybook to an existing Svelte application, cd to the top application directory and enter the following:

npx -p @storybook/cli sb init --type svelte

This takes a couple of minutes to complete and results in the following changes:

The supplied Button component dispatches an event when it is clicked. The index.stories.js file listens for the event and calls the action function that it imports from @storybook/addon-actions. Strings passed to the action function are logged at the bottom of the main section of the Storybook UI. The Clear button in the lower-right corner of the Actions area clears the list of logged actions.

To run Storybook locally, enter npm run storybook.

To add components to Storybook, add one .stories.js file for each component in the stories directory. These files should be similar to the provided index.stories.js file.

Each story renders a component in a different state. It is common for a .stories.js file to define multiple stories that render the same component in different states. Each state has a name that is displayed in the left nav below the component name. The two states for the provided Button component are “Text” and “Emoji”.

When new .stories.js files are created, refresh the browser where Storybook is running to see them in the left nav. When an existing .stories.js file is modified, Storybook automatically detects the changes and displays the modified story at the bottom of the left nav.

12.4.1 Storybook for Travel Packing app

Let’s define Storybook stories for the Travel Packing app components. The finished code can be found at http://mng.bz/pBqz.

The following listing shows stories for the Item component (see figure 12.11).

Figure 12.11 Item component in Storybook

Listing 12.16 Storybook stories for the Item component in stories/Item.stories.js

import {action} from '@storybook/addon-actions';
import Item from '../src/Item.svelte';
import '../public/global.css';                        
 
export default {title: 'Item'};
 
const getOptions = packed => ({
  Component: Item,
  props: {
    categoryId: 1,
    dnd: {},
    item: {id: 2, name: 'socks', packed}
  },
  on: {delete: action('item delete dispatched')}      
});
                                                      
export const unpacked = () => getOptions(false);
export const packed = () => getOptions(true);

This applies global styles from the Travel Packing app.

This writes to the Actions area of the Storybook UI to indicate that the action occurred.

Each story is defined by an exported function that returns an object describing the component to be rendered, props to be passed to it, and handling of events dispatched by the component.

Now that we have developed stories of our own, we no longer need the provided example files. The files button.svelte and index.stories.js can be deleted so they no longer appear inside Storybook.

The following listing shows stories for the Category component (see figure 12.12).


Figure 12.12 Category component in Storybook

Listing 12.17 Storybook stories for the Category component in stories/Category.stories.js

import {action} from '@storybook/addon-actions';
import Category from '../src/Category.svelte';
import '../public/global.css';
 
export default {title: 'Category'};
 
function getOptions(items) {
  const category = {id: 1, name: 'Clothes', items};
  return {
    Component: Category,
    props: {
      category,
      categories: {[category.id]: category},
      dnd: {},
      show: 'all'
    },
    on: {delete: action('category delete dispatched')}       
  };
}

export const empty = () => getOptions({});
export const nonEmpty = () =>
  getOptions({
    1: {id: 1, name: 'socks', packed: true},
    2: {id: 2, name: 'shoes', packed: false}
  });

This writes to the Actions area of the Storybook UI to indicate that the action occurred.

In the Travel Packing app, the App component provides some styling that will be missing in Storybook when we render components not nested in an App component. But we can simulate this by defining a StyleWrapper component that renders a given component in a div with specified styling applied to the div. The StyleWrapper component is used in a couple of the stories that follow.

Some of the components dispatch events to which their parent component listens. Recall that events only go to parent components. Inserting the StyleWrapper component between them breaks the ability of parent components to receive the events. To fix this, the StyleWrapper component must forward the events. The events in question are login and logout, so those are the events that are forwarded.

Listing 12.18 StyleWrapper component in stories/StyleWrapper.svelte

<script>
  export let component;
  export let style;
</script>
 
<div style={style}>
  <svelte:component this={component} on:login on:logout />     
</div>

This forwards login and logout events.

The following listing shows stories for the Checklist component (see figure 12.13). Each story has a name that is specified by the name of the function that defines it. These should be as descriptive as possible. The stories for the Item component were given the names unpacked and packed. The stories for the Category component were given the names empty and nonEmpty. However, sometimes there is no obvious name, and a generic name such as basic is used to indicate that basic use of the component is being demonstrated.

Figure 12.13 Checklist component in Storybook

SListing 12.19 torybook stories for the Checklist component in stories/Checklist.stories.js

import {action} from '@storybook/addon-actions';
import Checklist from '../src/Checklist.svelte';
import StyleWrapper from './StyleWrapper.svelte';
import '../public/global.css';

export default {title: 'Checklist'};

export const basic = () => ({
  Component: StyleWrapper,             
  props: {
    component: Checklist,
    style: `
      background-color: cornflowerblue;
      color: white;
      height: 100vh;
      padding: 1rem
    `
  },
  on: {logout: action('logout dispatched')}
});

This wraps the component being demonstrated in the StyleWrapper component.

The following listing shows stories for the Login component (see figure 12.14).

Figure 12.14 Login component in Storybook

Listing 12.20 Storybook stories for the Login component in stories/Login.stories.js

import {action} from '@storybook/addon-actions';
import StyleWrapper from './StyleWrapper.svelte';
import Login from '../src/Login.svelte';
import '../public/global.css';
 
export default {title: 'Login'};
 
export const basic = () => ({
  Component: StyleWrapper,
  props: {
    component: Login,
    style: `
      background-color: cornflowerblue;
      height: 100vh;
      padding: 1rem
    `
  },
  on: {login: action('login dispatched')}
});

We want to allow Storybook users to configure the title and content of a Dialog component. To enable this, we can define the following DialogWrapper component that provides inputs for the title and content. This component is only used by Storybook, not the Travel Packing app.

Listing 12.21 DialogWrapper component in stories/DialogWrapper.svelte

<script>
  import Dialog from '../src/Dialog.svelte';
  let content = 'This is some\\nlong content.';
  let dialog;
  let title = 'My Dialog Title';
 
  $: lines = content.split('\\n');       
</script>
 
<section>
  <label>
    Title
    <input bind:value={title} />
  </label>
 
  <label>
    Content
    <textarea bind:value={content} />
    Insert \n to get multi-line content.
  </label>
 
  <button on:click={() => dialog.showModal()}>Show Dialog</button>
 
  <Dialog {title} bind:dialog={dialog} on:close={() => dialog.close()}>
    {#each lines as line}
      <div>{line}</div>
    {/each}
  </Dialog>
</section>

<style>
  input, textarea {
    margin: 0 1rem 1rem 1rem;
  }
 
  label {
    color: white;
    display: flex;
    align-items: flex-start;
  }
 
  section {
    background-color: cornflowerblue;
    height: 100vh;
    padding: 1rem;
  }
</style>

The content string is split on occurrences of \n, which is the escape sequence for a newline character, to support multiline content.

The following listing shows stories for the Dialog component (see figure 12.15).

Figure 12.15 Dialog component in Storybook

Listing 12.22 Storybook stories for the Dialog component in stories/Dialog.stories.js

import DialogWrapper from './DialogWrapper.svelte';
import '../public/global.css';
 
export default {title: 'Dialog'};
 
export const basic = () => ({Component: DialogWrapper});

We have now added all the components in the Travel Packing app to Storybook.

It may be desirable to generate and deploy a static version of Storybook, including all the registered components, to allow others to view the components. To generate a static version of Storybook, enter npm run build-storybook. This creates the storybook-static directory containing all the required HTML and JavaScript files. All required CSS rules are compiled into the JavaScript. This directory can be copied to a web server. To view it locally, simply open the index.html file in a web browser.

Storybook provides a quick way to see a list of all the components used by an app and to interact with them. If a bug is discovered in a component, it can be debugged in the context of Storybook, which is easier than debugging in an app that uses it.

In the next chapter you’ll see options for deploying Svelte applications.

Summary