Skip to the code
cf.

Shopping cart with quantities

Build App: the cart of a small tea shop. It takes products, the teas for sale as { id, name, price } with the price in dollars, and initialCart, the cart to start with as a list of { id, quantity } lines (an empty cart when it isn't given). When products isn't given, as in the Preview, use a few teas of your own.

Each product has a button named Add and the tea's name: Add Green tea. Adding a tea that isn't in the cart puts a line for it at the end, with a quantity of 1. Adding one that's already there raises that line's quantity instead.

The cart is a list labelled Cart (<ul aria-label="Cart">). Each line shows the name and quantity as Green tea × 2, a − button labelled One less Green tea, a + button labelled One more Green tea, and the line's price, $9.00. Put Green tea × 2 and $9.00 each in an element of its own, like the <span>s below, so each can be found by its text. Taking a quantity below 1 removes the line.

Under the cart, Items: 3 counts every tea in it and Total: $13.50 adds up the lines, with two decimal places. When the cart is empty, Your cart is empty. shows instead of the list, with Items: 0 and Total: $0.00.

Never change products, initialCart, or the objects in them. The tests may freeze them, and they check that initialCart is the same after you've used the cart. Every update makes new arrays and objects instead.

<App
  products={[
    { id: "green", name: "Green tea", price: 4.5 },
    { id: "oolong", name: "Oolong", price: 6.25 },
  ]}
  initialCart={[{ id: "green", quantity: 2 }]}
/>
 
// one line of the cart:
<li>
  <span>Green tea × 2</span>
  <button aria-label="One less Green tea">−</button>
  <button aria-label="One more Green tea">+</button>
  <span>$9.00</span>
</li>

What the tests check

  1. starts with an empty cart
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    render(<App products={products} />);
    expect(screen.getByText("Your cart is empty.")).toBeInTheDocument();
    expect(screen.queryByRole("list", { name: "Cart" })).not.toBeInTheDocument();
    expect(screen.getByText("Items: 0")).toBeInTheDocument();
    expect(screen.getByText("Total: $0.00")).toBeInTheDocument();
    
  2. Add puts a line in the cart with a quantity of 1
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    render(<App products={products} />);
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    const cart = screen.getByRole("list", { name: "Cart" });
    expect(within(cart).getByText("Green tea × 1")).toBeInTheDocument();
    expect(within(cart).getByText("$4.50")).toBeInTheDocument();
    expect(screen.getByText("Items: 1")).toBeInTheDocument();
    expect(screen.getByText("Total: $4.50")).toBeInTheDocument();
    expect(screen.queryByText("Your cart is empty.")).not.toBeInTheDocument();
    
  3. adding a tea that's in the cart raises its quantity
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    render(<App products={products} />);
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    const cart = screen.getByRole("list", { name: "Cart" });
    expect(within(cart).getAllByRole("listitem")).toHaveLength(1);
    expect(within(cart).getByText("Green tea × 2")).toBeInTheDocument();
    expect(within(cart).getByText("$9.00")).toBeInTheDocument();
    
  4. new teas go at the end of the cart
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    render(<App products={products} />);
    await user.click(screen.getByRole("button", { name: "Add Oolong" }));
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    const lines = within(screen.getByRole("list", { name: "Cart" })).getAllByRole("listitem");
    expect(lines[0]).toHaveTextContent("Oolong × 1");
    expect(lines[1]).toHaveTextContent("Green tea × 1");
    
  5. + and − change the quantity and the totals
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    render(<App products={products} />);
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    await user.click(screen.getByRole("button", { name: "One more Green tea" }));
    await user.click(screen.getByRole("button", { name: "One more Green tea" }));
    expect(screen.getByText("Green tea × 3")).toBeInTheDocument();
    expect(screen.getByText("Items: 3")).toBeInTheDocument();
    expect(screen.getByText("Total: $13.50")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "One less Green tea" }));
    expect(screen.getByText("Green tea × 2")).toBeInTheDocument();
    expect(screen.getByText("Total: $9.00")).toBeInTheDocument();
    
  6. − at a quantity of 1 removes the line
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    render(<App products={products} />);
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    await user.click(screen.getByRole("button", { name: "Add Oolong" }));
    await user.click(screen.getByRole("button", { name: "One less Green tea" }));
    const cart = screen.getByRole("list", { name: "Cart" });
    expect(within(cart).getAllByRole("listitem")).toHaveLength(1);
    expect(screen.queryByText(/Green tea ×/)).not.toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "One less Oolong" }));
    expect(screen.getByText("Your cart is empty.")).toBeInTheDocument();
    expect(screen.getByText("Total: $0.00")).toBeInTheDocument();
    
  7. adds up every line
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    render(<App products={products} />);
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    await user.click(screen.getByRole("button", { name: "Add Oolong" }));
    await user.click(screen.getByRole("button", { name: "Add Masala chai" }));
    expect(screen.getByText("Items: 4")).toBeInTheDocument();
    expect(screen.getByText("Total: $20.25")).toBeInTheDocument();
    
  8. starts from initialCart
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    render(<App products={products} initialCart={[{ id: "oolong", quantity: 3 }]} />);
    expect(screen.getByText("Oolong × 3")).toBeInTheDocument();
    expect(within(screen.getByRole("list", { name: "Cart" })).getByText("$18.75")).toBeInTheDocument();
    expect(screen.getByText("Items: 3")).toBeInTheDocument();
    expect(screen.getByText("Total: $18.75")).toBeInTheDocument();
    
  9. leaves initialCart as it was
    const products = [
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ];
    const initialCart = [{ id: "green", quantity: 2 }, { id: "oolong", quantity: 1 }];
    const before = JSON.parse(JSON.stringify(initialCart));
    render(<App products={products} initialCart={initialCart} />);
    await user.click(screen.getByRole("button", { name: "One more Green tea" }));
    await user.click(screen.getByRole("button", { name: "Add Oolong" }));
    await user.click(screen.getByRole("button", { name: "Add Masala chai" }));
    await user.click(screen.getByRole("button", { name: "One less Masala chai" }));
    expect(screen.getByText("Green tea × 3")).toBeInTheDocument();
    expect(screen.getByText("Oolong × 2")).toBeInTheDocument();
    expect(initialCart).toEqual(before);
    
  10. works with frozen products and a frozen cart
    const freeze = (list) => Object.freeze(list.map((item) => Object.freeze(item)));
    const products = freeze([
      { id: "green", name: "Green tea", price: 4.5 },
      { id: "oolong", name: "Oolong", price: 6.25 },
      { id: "chai", name: "Masala chai", price: 5 },
    ]);
    render(<App products={products} initialCart={freeze([{ id: "green", quantity: 1 }])} />);
    await user.click(screen.getByRole("button", { name: "Add Green tea" }));
    await user.click(screen.getByRole("button", { name: "Add Masala chai" }));
    await user.click(screen.getByRole("button", { name: "One more Masala chai" }));
    await user.click(screen.getByRole("button", { name: "One less Green tea" }));
    expect(screen.getByText("Green tea × 1")).toBeInTheDocument();
    expect(screen.getByText("Masala chai × 2")).toBeInTheDocument();
    expect(screen.getByText("Total: $14.50")).toBeInTheDocument();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).