This episode covers structuring end-to-end test scenarios: login, checkout, and multi-page journeys, managing the state of the application under test with cy.session, and deterministic data setup and teardown.

Component testing strengthens the foundation. Now you tie it all together: complete end-to-end scenarios from UI to backend. Episode 10 covers structuring E2E test scenarios, login, checkout, and multi-page journeys, managing the state of the application under test, and deterministic data setup and teardown.
E2E is the top layer of the test pyramid — the closest to the real user experience. However, that power comes at the cost of speed and stability, so disciplined scenario design becomes the deciding factor for success.
The core principle: one test verifies one user flow. Do not combine login, search, and checkout into one giant it — it is hard to trace when it fails and expensive to rerun.
describe("Alur pencarian produk", () => {
it("menampilkan hasil yang sesuai kata kunci", () => {
cy.visit("/katalog");
cy.get("[data-cy=cari]").type("kopi");
cy.get("[data-cy=hasil]").should("contain.text", "Kopi Gayo");
});
});cy.visit("/katalog") opens the starting page of the scenario. This test has a single goal: making sure the search shows the right results. There are no unneeded steps.
Structure the steps along the user journey, not the implementation order. Start from a real state, perform actions, then verify outcomes the user can see. Test language should be readable by non-technical teams:
it("memesan produk dari halaman detail", () => {
cy.visit("/produk/kopi-gayo");
cy.get("[data-cy=jumlah]").type("2");
cy.get("[data-cy=masukkan-keranjang]").click();
cy.get("[data-cy=keranjang]").should("have.text", "2");
});cy.get("[data-cy=jumlah]").type("2") mimics a user entering the order quantity. Each line describes one real step: open the product, set the quantity, add to cart, then confirm the cart updated.
Login is the foundation of many scenarios. Use the custom command created in episode 7 so you do not repeat the details:
it("berhasil login dan diarahkan ke dashboard", () => {
cy.visit("/login");
cy.get("[data-cy=email]").type("user@example.com");
cy.get("[data-cy=password]").type("rahasia123");
cy.get("[data-cy=submit]").click();
cy.url().should("include", "/dashboard");
cy.contains("Selamat datang").should("be.visible");
});cy.url().should("include", "/dashboard") verifies the redirect after login. The second assertion ensures the page content actually loaded — not just that the URL changed.
Checkout usually crosses several pages: cart, address, payment, confirmation. Write it as one organized test with a step per page:
it("menyelesaikan checkout", () => {
cy.visit("/checkout");
cy.get("[data-cy=alamat]").type("Jl. Merdeka 1");
cy.get("[data-cy=lanjut-pembayaran]").click();
cy.get("[data-cy=kartu]").type("4111 1111 1111 1111");
cy.get("[data-cy=konfirmasi]").click();
cy.contains("Pesanan berhasil dibuat").should("be.visible");
cy.url().should("include", "/pesanan-selesai");
});cy.get("[data-cy=lanjut-pembayaran]").click() moves the test to the next page. The pattern fill a form → continue → fill the next form → verify the final result is the core of multi-page journey testing.
Good tests do not depend on previous user history. Every test sets up its own state. There are several ways: injecting data through the task API, stubbing responses, or setting a session token:
cy.visit("/");
cy.window().then((win) => {
win.localStorage.setItem("token", "token-tiruan");
});
cy.reload();win.localStorage.setItem("token", "token-tiruan") injects a mock session before reloading. This way the login flow does not need to run in every test — saving time without sacrificing realism.
Sometimes, on the other hand, you want to mimic a user staying logged in across pages. Cypress provides cy.session() to store sessions:
beforeEach(() => {
cy.session("login-user", () => {
cy.visit("/login");
cy.get("[data-cy=email]").type("user@example.com");
cy.get("[data-cy=password]").type("rahasia123");
cy.get("[data-cy=submit]").click();
});
});cy.session("login-user", ...) runs the login block once and then stores the session state. Subsequent tests reuse the stored session — speeding up execution while still mimicking a logged-in user.
Data leaking between tests is a source of flakiness. Use hooks for deterministic setup and teardown:
beforeEach(() => {
cy.task("seedDatabase");
cy.visit("/");
});
afterEach(() => {
cy.task("clearDatabase");
});cy.task("seedDatabase") calls a Node function on the Cypress side to inject test data. cy.task("clearDatabase") cleans up after the test. We will dig into implementing this task API in episode 18.
If a test fails halfway through, afterEach still runs — make sure your teardown does not assume the test succeeded. Avoid sending cleanup requests from inside the test; put everything in hooks so it always executes regardless of the test outcome.
Info
Combine cy.session() with cy.task(): sessions for user state, tasks for backend data. This separation keeps tests fast and gives each state source a clear owner.
Episode 10 wove interactions into real flows: one test per scenario, multi-page login and checkout, state management with cy.session() and localStorage, and data setup and teardown via the task API and hooks.
Key takeaways:
it blocks.cy.session() stores a login session so subsequent tests run fast.localStorage, the task API, or stubs.In the next episode, episode 11, we will cover dashboard and reporting — Cypress Dashboard features, video recording and screenshots, the Mochawesome reporter for CI, and how to analyze test runs and debug failures. Your test results start becoming readable by others.