├── .gitignore ├── .nojekyll ├── .prettierignore ├── README.md ├── demo-vue ├── .gitignore ├── .vscode │ └── extensions.json ├── README.md ├── cypress.config.ts ├── cypress │ ├── fixtures │ │ └── example.json │ ├── support │ │ ├── commands.ts │ │ ├── component-index.html │ │ └── component.ts │ └── videos │ │ ├── computed │ │ └── Numbers.cy.ts.mp4 │ │ └── getting-started │ │ └── Greeter.cy.ts.mp4 ├── index.html ├── package.json ├── postcss.config.cjs ├── public │ └── vite.svg ├── src │ ├── App.vue │ ├── assets │ │ └── vue.svg │ ├── components │ │ └── HelloWorld.vue │ ├── computed │ │ ├── Numbers.cy.ts │ │ ├── Numbers.vue │ │ └── numbers.ts │ ├── getting-started │ │ ├── Greeter.cy.ts │ │ └── Greeter.vue │ ├── main.ts │ ├── pinia │ │ ├── Todos.cy.ts │ │ ├── Todos.vue │ │ └── store.ts │ ├── simulating-user-input │ │ ├── Keyboard.cy.ts │ │ └── Keyboard.vue │ ├── style.css │ ├── vite-env.d.ts │ ├── vue-router │ │ ├── BookTable.cy.ts │ │ ├── BookTable.vue │ │ └── router.ts │ └── vuetify │ │ ├── SignUp.cy.ts │ │ └── SignUp.vue ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock ├── deploy.sh ├── docs ├── .vitepress │ ├── cache │ │ └── deps │ │ │ ├── @theme_index.js │ │ │ ├── @theme_index.js.map │ │ │ ├── _metadata.json │ │ │ ├── package.json │ │ │ ├── vue.js │ │ │ └── vue.js.map │ └── config.js ├── computed-properties.md ├── getting-started.md ├── images │ ├── getting-started-1.png │ ├── getting-started-2.png │ ├── input-1.png │ ├── input-2.png │ ├── pinia-1.png │ ├── pinia-2.png │ ├── props.png │ ├── router-1.png │ ├── router-2.png │ ├── router-3.png │ ├── router-4.png │ ├── vuetify-1.png │ ├── vuetify-2.png │ ├── vuetify-3.png │ └── vuetify-4.png ├── index.md ├── introduction.md ├── pinia.md ├── simulating-user-input.md ├── testing-props.md ├── vue-router.md └── vuetify.md ├── package.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | docs/.vitepress/dist 3 | -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/.nojekyll -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vitepress 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cypress Testing Handbook 2 | 3 | A (heavily WIP) guide on using Cypress to test your components. 4 | 5 | https://cypress-testing-handbook.netlify.app 6 | -------------------------------------------------------------------------------- /demo-vue/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /demo-vue/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /demo-vue/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + TypeScript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo-vue/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-vue", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc && vite build", 9 | "preview": "vite preview", 10 | "test": "cypress open --component --browser chrome" 11 | }, 12 | "dependencies": { 13 | "autoprefixer": "^10.4.13", 14 | "cypress": "^12.4.1", 15 | "pinia": "^2.0.30", 16 | "postcss": "^8.4.21", 17 | "vue": "^3.2.45", 18 | "vue-router": "^4.1.6", 19 | "vuetify": "^3.1.3" 20 | }, 21 | "devDependencies": { 22 | "@vitejs/plugin-vue": "^4.0.0", 23 | "tailwindcss": "^3.2.4", 24 | "typescript": "^4.9.5", 25 | "vite": "^4.0.0", 26 | "vue-tsc": "^1.0.11" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo-vue/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /demo-vue/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-vue/src/App.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 19 | -------------------------------------------------------------------------------- /demo-vue/src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo-vue/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 33 | 34 | 39 | -------------------------------------------------------------------------------- /demo-vue/src/computed/Numbers.cy.ts: -------------------------------------------------------------------------------- 1 | import { contains } from "cypress/types/jquery"; 2 | import Numbers from "./Numbers.vue"; 3 | 4 | describe("", () => { 5 | it("renders", () => { 6 | cy.mount(Numbers, { 7 | props: { 8 | parity: "odd", 9 | }, 10 | }); 11 | 12 | for (const i of [1, 3, 5, 7, 9]) { 13 | cy.get("li").contains(i).should("exist"); 14 | } 15 | 16 | for (const i of [-1, 0, 2, 4, 6, 8, 10, 11]) { 17 | cy.get("li").contains(i).should("not.exist"); 18 | } 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /demo-vue/src/computed/Numbers.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 22 | -------------------------------------------------------------------------------- /demo-vue/src/computed/numbers.ts: -------------------------------------------------------------------------------- 1 | export type Parity = "odd" | "even"; 2 | 3 | export function getNumbers(parity: Parity): number[] { 4 | const evens: number[] = []; 5 | const odds: number[] = []; 6 | 7 | for (let i = 1; i < 10; i++) { 8 | if (i % 2 === 0) { 9 | evens.push(i); 10 | } else { 11 | odds.push(i); 12 | } 13 | } 14 | 15 | return parity === "odd" ? odds : evens; 16 | } 17 | -------------------------------------------------------------------------------- /demo-vue/src/getting-started/Greeter.cy.ts: -------------------------------------------------------------------------------- 1 | import Greeter from "./Greeter.vue"; 2 | 3 | describe("", () => { 4 | it("renders a default message when no name prop is provided", () => { 5 | cy.mount(Greeter); 6 | cy.get("h1").contains("Hello, World"); 7 | }); 8 | 9 | it("renders a message with the name", () => { 10 | cy.mount(Greeter, { 11 | props: { 12 | name: "Lachlan", 13 | }, 14 | }); 15 | cy.get("h1").contains("Hello, Lachlan"); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /demo-vue/src/getting-started/Greeter.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /demo-vue/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import "./style.css"; 3 | import { RouterView } from 'vue-router' 4 | 5 | import "vuetify/styles"; 6 | import { createVuetify } from "vuetify"; 7 | import * as components from "vuetify/components"; 8 | import * as directives from "vuetify/directives"; 9 | import { buildRouter } from "./vue-router/router"; 10 | 11 | const vuetify = createVuetify({ 12 | components, 13 | directives, 14 | }); 15 | 16 | const router = buildRouter(); 17 | 18 | const app = createApp(RouterView); 19 | app.use(vuetify); 20 | app.use(router); 21 | 22 | app.mount("#app"); 23 | -------------------------------------------------------------------------------- /demo-vue/src/pinia/Todos.cy.ts: -------------------------------------------------------------------------------- 1 | import Todos from "./Todos.vue"; 2 | import { useTodos } from "./store"; 3 | 4 | describe("Todos", () => { 5 | beforeEach(() => { 6 | const todosStore = useTodos(); 7 | 8 | todosStore.addTodo("👨‍💻 Write code"); 9 | todosStore.addTodo("🕵️ Add some tests"); 10 | todosStore.addTodo("📄 Don't forget documentation!"); 11 | todosStore.addTodo("🚢 Ship!"); 12 | }); 13 | 14 | it.only("renders a list of todos", () => { 15 | cy.mountWithPinia(Todos); 16 | 17 | const todosStore = useTodos(); 18 | expect(todosStore.state.filter).to.eql("all"); 19 | expect(todosStore.filteredTodos).to.have.length(4); 20 | 21 | for (const todo of todosStore.filteredTodos) { 22 | cy.get("label").contains(todo.text); 23 | } 24 | }); 25 | 26 | it("completes todos", () => { 27 | cy.mountWithPinia(Todos); 28 | 29 | const todosStore = useTodos(); 30 | expect(todosStore.state.filter).to.eql("all"); 31 | expect(todosStore.filteredTodos).to.have.length(4); 32 | 33 | cy.get("label") 34 | .contains("Write code") 35 | .click() 36 | .then(() => { 37 | expect(todosStore.completedTodos).to.have.length(1); 38 | expect(todosStore.completedTodos[0].text).to.contain("Write code"); 39 | }); 40 | }); 41 | 42 | it("filters todos", () => { 43 | cy.mountWithPinia(Todos); 44 | 45 | cy.get("label") 46 | .contains("Write code") 47 | .click() 48 | .get('[data-cy="todo"]') 49 | .should("have.length", 4); 50 | 51 | cy.get("label") 52 | .contains("completed") 53 | .click() 54 | .get('[data-cy="todo"]') 55 | .should("have.length", 1); 56 | 57 | cy.get("label") 58 | .contains("outstanding") 59 | .click() 60 | .get('[data-cy="todo"]') 61 | .should("have.length", 3); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /demo-vue/src/pinia/Todos.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 42 | -------------------------------------------------------------------------------- /demo-vue/src/pinia/store.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from "pinia"; 2 | import { computed, reactive } from "vue"; 3 | 4 | interface Todo { 5 | id: number; 6 | text: string; 7 | completed: boolean; 8 | } 9 | 10 | export const filterTypes = ["all", "completed", "outstanding"] as const; 11 | 12 | interface TodosState { 13 | todos: Todo[]; 14 | filter: (typeof filterTypes)[number]; 15 | nextId: 0; 16 | } 17 | 18 | export const useTodos = defineStore("todos", () => { 19 | const state = reactive({ 20 | todos: [], 21 | filter: "all", 22 | nextId: 0, 23 | }); 24 | 25 | const completedTodos = computed(() => { 26 | return state.todos.filter((todo) => todo.completed); 27 | }); 28 | 29 | const incompleteTodos = computed(() => { 30 | return state.todos.filter((todo) => !todo.completed); 31 | }); 32 | 33 | const filteredTodos = computed(() => { 34 | if (state.filter === "completed") { 35 | return completedTodos.value; 36 | } else if (state.filter === "outstanding") { 37 | return incompleteTodos.value; 38 | } 39 | return state.todos; 40 | }); 41 | 42 | function addTodo(text: string) { 43 | state.todos.push({ text, id: state.nextId++, completed: false }); 44 | } 45 | 46 | return { 47 | state, 48 | filteredTodos, 49 | completedTodos, 50 | incompleteTodos, 51 | addTodo, 52 | }; 53 | }); 54 | -------------------------------------------------------------------------------- /demo-vue/src/simulating-user-input/Keyboard.cy.ts: -------------------------------------------------------------------------------- 1 | import Keyboard from "./Keyboard.vue"; 2 | 3 | describe("", () => { 4 | it("greets the user", () => { 5 | cy.mount(Keyboard); 6 | cy.get("input[name='username']").type("Lachlan"); 7 | cy.get("p").should("have.text", "Hello, Lachlan!"); 8 | 9 | cy.get("input[name='username']").clear().type("Lily"); 10 | cy.get("p").should("have.text", "Hello, Lily!"); 11 | 12 | // Intercept *before* request is made. 13 | // Use .as('sign_up') so we can cy.wait() later. 14 | cy.intercept("/sign_up", (req) => { 15 | expect(req.body).to.eq( 16 | JSON.stringify({ 17 | username: "Lily", 18 | }) 19 | ); 20 | req.reply("OK"); 21 | }).as("sign_up"); 22 | 23 | cy.get("input[name='username']").type("{enter}"); 24 | 25 | cy.wait("@sign_up"); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /demo-vue/src/simulating-user-input/Keyboard.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /demo-vue/src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /demo-vue/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo-vue/src/vue-router/BookTable.cy.ts: -------------------------------------------------------------------------------- 1 | import BookTable from "./BookTable.vue"; 2 | 3 | describe("BookTable", () => { 4 | it("bookmarks a book", () => { 5 | cy.intercept("/bookmarks", (req) => { 6 | expect(req.body).to.eq( 7 | JSON.stringify({ 8 | book_id: "2", 9 | }) 10 | ); 11 | req.reply("OK"); 12 | }).as("bookmarks"); 13 | 14 | cy.mountWithRouter(BookTable, { 15 | global: { 16 | stubs: { 17 | RouterView: true, 18 | }, 19 | }, 20 | }).as("wrapper"); 21 | 22 | cy.get("[data-cy='Snow White']").within(() => { 23 | cy.get("button").contains("Bookmark").click(); 24 | }); 25 | cy.wait("@bookmarks"); 26 | 27 | cy.get("@wrapper").then(({ wrapper }) => 28 | expect(wrapper.vm.$router.currentRoute.value.fullPath).to.eql("/") 29 | ); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /demo-vue/src/vue-router/BookTable.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 55 | -------------------------------------------------------------------------------- /demo-vue/src/vue-router/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | import BookTable from "./BookTable.vue"; 3 | import App from "../App.vue"; 4 | import { defineComponent, h } from "vue"; 5 | 6 | export const buildRouter = (history = createWebHistory()) => { 7 | return createRouter({ 8 | history, 9 | routes: [ 10 | { 11 | path: "/", 12 | component: App, 13 | }, 14 | { 15 | path: "/books", 16 | component: BookTable, 17 | children: [ 18 | { 19 | path: ":id", 20 | component: defineComponent({ 21 | setup() { 22 | // placeholder 23 | return () => h("div", "OK"); 24 | }, 25 | }), 26 | }, 27 | ], 28 | }, 29 | ], 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /demo-vue/src/vuetify/SignUp.cy.ts: -------------------------------------------------------------------------------- 1 | import SignUp from "./SignUp.vue"; 2 | 3 | describe("SignUp", () => { 4 | it("validates form and submits correct payload", () => { 5 | cy.mountWithVuetify(SignUp); 6 | 7 | // Submit - errors are shown 8 | cy.get("button").contains("Submit").click(); 9 | 10 | // Assert errors are shown 11 | cy.get('[role="alert"]').contains("Name is required."); 12 | cy.get('[role="alert"]').contains("Must be a valid email."); 13 | 14 | // Fill in form 15 | cy.get('[name="username"]').type("Lachlan"); 16 | cy.get('[name="email"]').type("test@cypress.io"); 17 | 18 | // Intercept request - backend doesn't exist 19 | cy.intercept("/users/sign_up", "OK").as("submit"); 20 | 21 | // Assert correct payload 22 | cy.get("button").contains("Submit").click(); 23 | cy.wait("@submit") 24 | .its("request.body") 25 | .should( 26 | "eql", 27 | JSON.stringify({ 28 | username: "Lachlan", 29 | email: "test@cypress.io", 30 | }) 31 | ); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /demo-vue/src/vuetify/SignUp.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 76 | -------------------------------------------------------------------------------- /demo-vue/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,vue}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /demo-vue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "strict": true, 8 | "jsx": "preserve", 9 | "noImplicitThis": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "esModuleInterop": true, 13 | "lib": ["ESNext", "DOM"], 14 | "skipLibCheck": true, 15 | "types": ["cypress"], 16 | "noEmit": true 17 | }, 18 | "include": [ 19 | "src/**/*.ts", 20 | "src/**/*.d.ts", 21 | "src/**/*.tsx", 22 | "src/**/*.vue", 23 | "cypress/**/*.ts" 24 | ], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /demo-vue/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /demo-vue/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | }); 8 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | git branch -D gh-pages 2 | git checkout -b gh-pages 3 | yarn docs:build 4 | mv docs/.vitepress/dist/ temp 5 | rm -rf docs 6 | mv temp docs 7 | git add -A 8 | git commit -m "deploy" 9 | git push --set-upstream origin gh-pages -f 10 | git checkout main 11 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/@theme_index.js: -------------------------------------------------------------------------------- 1 | // node_modules/vitepress/dist/client/theme-default/index.js 2 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/fonts.css"; 3 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/vars.css"; 4 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/base.css"; 5 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/utils.css"; 6 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/components/custom-block.css"; 7 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code.css"; 8 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css"; 9 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/components/vp-doc.css"; 10 | import "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/styles/components/vp-sponsor.css"; 11 | import VPBadge from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPBadge.vue"; 12 | import Layout from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/Layout.vue"; 13 | import NotFound from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/NotFound.vue"; 14 | import { default as default2 } from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPHomeHero.vue"; 15 | import { default as default3 } from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPHomeFeatures.vue"; 16 | import { default as default4 } from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPHomeSponsors.vue"; 17 | import { default as default5 } from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPDocAsideSponsors.vue"; 18 | import { default as default6 } from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPTeamPage.vue"; 19 | import { default as default7 } from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageTitle.vue"; 20 | import { default as default8 } from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPTeamPageSection.vue"; 21 | import { default as default9 } from "/Users/lachlanmiller/code/dump/cypress-testing-handbook/node_modules/vitepress/dist/client/theme-default/components/VPTeamMembers.vue"; 22 | var theme = { 23 | Layout, 24 | NotFound, 25 | enhanceApp: ({ app }) => { 26 | app.component("Badge", VPBadge); 27 | } 28 | }; 29 | var theme_default_default = theme; 30 | export { 31 | default5 as VPDocAsideSponsors, 32 | default3 as VPHomeFeatures, 33 | default2 as VPHomeHero, 34 | default4 as VPHomeSponsors, 35 | default9 as VPTeamMembers, 36 | default6 as VPTeamPage, 37 | default8 as VPTeamPageSection, 38 | default7 as VPTeamPageTitle, 39 | theme_default_default as default 40 | }; 41 | //# sourceMappingURL=@theme_index.js.map 42 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/@theme_index.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": ["../../../../node_modules/vitepress/dist/client/theme-default/index.js"], 4 | "sourcesContent": ["import './styles/fonts.css';\nimport './styles/vars.css';\nimport './styles/base.css';\nimport './styles/utils.css';\nimport './styles/components/custom-block.css';\nimport './styles/components/vp-code.css';\nimport './styles/components/vp-code-group.css';\nimport './styles/components/vp-doc.css';\nimport './styles/components/vp-sponsor.css';\nimport VPBadge from './components/VPBadge.vue';\nimport Layout from './Layout.vue';\nimport NotFound from './NotFound.vue';\nexport { default as VPHomeHero } from './components/VPHomeHero.vue';\nexport { default as VPHomeFeatures } from './components/VPHomeFeatures.vue';\nexport { default as VPHomeSponsors } from './components/VPHomeSponsors.vue';\nexport { default as VPDocAsideSponsors } from './components/VPDocAsideSponsors.vue';\nexport { default as VPTeamPage } from './components/VPTeamPage.vue';\nexport { default as VPTeamPageTitle } from './components/VPTeamPageTitle.vue';\nexport { default as VPTeamPageSection } from './components/VPTeamPageSection.vue';\nexport { default as VPTeamMembers } from './components/VPTeamMembers.vue';\nconst theme = {\n Layout,\n NotFound,\n enhanceApp: ({ app }) => {\n app.component('Badge', VPBadge);\n }\n};\nexport default theme;\n"], 5 | "mappings": ";AAAA,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO;AACP,OAAO,aAAa;AACpB,OAAO,YAAY;AACnB,OAAO,cAAc;AACrB,SAAoB,WAAXA,gBAA6B;AACtC,SAAoB,WAAXA,gBAAiC;AAC1C,SAAoB,WAAXA,gBAAiC;AAC1C,SAAoB,WAAXA,gBAAqC;AAC9C,SAAoB,WAAXA,gBAA6B;AACtC,SAAoB,WAAXA,gBAAkC;AAC3C,SAAoB,WAAXA,gBAAoC;AAC7C,SAAoB,WAAXA,gBAAgC;AACzC,IAAM,QAAQ;AAAA,EACV;AAAA,EACA;AAAA,EACA,YAAY,CAAC,EAAE,IAAI,MAAM;AACrB,QAAI,UAAU,SAAS,OAAO;AAAA,EAClC;AACJ;AACA,IAAO,wBAAQ;", 6 | "names": ["default"] 7 | } 8 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "hash": "06d78950", 3 | "browserHash": "f5013cc7", 4 | "optimized": { 5 | "vue": { 6 | "src": "../../../../node_modules/vue/dist/vue.runtime.esm-bundler.js", 7 | "file": "vue.js", 8 | "fileHash": "06050b08", 9 | "needsInterop": false 10 | }, 11 | "@theme/index": { 12 | "src": "../../../../node_modules/vitepress/dist/client/theme-default/index.js", 13 | "file": "@theme_index.js", 14 | "fileHash": "0d696d57", 15 | "needsInterop": false 16 | } 17 | }, 18 | "chunks": {} 19 | } -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/package.json: -------------------------------------------------------------------------------- 1 | {"type":"module"} -------------------------------------------------------------------------------- /docs/.vitepress/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | title: "Cypress Testing Handbook", 3 | description: "Just playing around.", 4 | // base: '/cypress-testing-handbook/', 5 | themeConfig: { 6 | sidebar: [ 7 | { 8 | text: "Basics", 9 | items: [ 10 | { text: "Introduction", link: "/introduction" }, 11 | { text: "Getting Started", link: "/getting-started" }, 12 | { text: "Testing Props", link: "/testing-props" }, 13 | { text: "Computed Properties", link: "/computed-properties" }, 14 | ], 15 | }, 16 | { 17 | text: "User Interactions", 18 | items: [ 19 | { text: "Typing & Forms", link: "/simulating-user-input" }, 20 | ] 21 | }, 22 | { 23 | text: "Integrating with Third Party Libraries", 24 | items: [ 25 | { text: "Vuetify", link: "/vuetify" }, 26 | { text: "Pinia", link: "/pinia" }, 27 | { text: "Vue Router", link: "/vue-router" }, 28 | ], 29 | }, 30 | ], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /docs/computed-properties.md: -------------------------------------------------------------------------------- 1 | # Computed Properties 2 | 3 | Computed properties are one of my favorite features of Vue! Compared to Props, they can be more complex, so testing them can also a bit more involved. 4 | 5 | We will take a look at a simple example, and discuss a few different approaches, and some general testing philosophy. 6 | 7 | ## The Component 8 | 9 | The component we will test is the `` component. The numbers change, depending on the prop passed - valid options are `even` or `odd`. 10 | 11 | The component looks like this: 12 | 13 | ```vue 14 | 36 | 37 | 44 | ``` 45 | 46 | ## What To Test? 47 | 48 | This component has some complexity, but ultimately, the only thing that changes what is rendered is the `parity` prop. There are several approaches to testing this feature, though, depending on your philosophy. Let's take a look at each. 49 | 50 | ## Testing With Cypress - No Isolation 51 | 52 | This particular example is simple enough to test using a Cypress Component Test: 53 | 54 | ```ts 55 | import Numbers from "./Numbers.vue"; 56 | 57 | describe("", () => { 58 | it("renders", () => { 59 | cy.mount(Numbers, { 60 | props: { 61 | parity: "odd", 62 | }, 63 | }); 64 | 65 | cy.get("li").contains("1"); 66 | cy.get("li").contains("3"); 67 | cy.get("li").contains("5"); 68 | cy.get("li").contains("7"); 69 | cy.get("li").contains("9"); 70 | }); 71 | }); 72 | ``` 73 | 74 | This _passes_, but we can do better. Let's also assert: 75 | 76 | - No even numbers are rendered 77 | - No _additional_ odd numbers 78 | 79 | ```ts 80 | import Numbers from "./Numbers.vue"; 81 | 82 | describe("", () => { 83 | it("renders", () => { 84 | cy.mount(Numbers, { 85 | props: { 86 | parity: "odd", 87 | }, 88 | }); 89 | 90 | for (const i of [1, 3, 5, 7, 9]) { 91 | cy.get("li").contains(i).should("exist"); 92 | } 93 | 94 | for (const i of [-1, 0, 2, 4, 6, 8, 10, 11]) { 95 | cy.get("li").contains(i).should("not.exist"); 96 | } 97 | }); 98 | }); 99 | ``` 100 | 101 | This is more concise, _and_ a lot more thorough. The `shoud("exist")` in `cy.get("li").contains(i).should("exist")` is technically not needed, but I like the parallel between `should("exist")` and `should("not.exist")`. The test now doubles as documentation - it's clear what the intent of this component is. 102 | 103 | Writing the test for `parity: "even"` is basically the same - I will leave this as an exercise. I would recomend just duplicating the test and reversing the conditions. 104 | 105 | ## More Complex Logic 106 | 107 | Testing both the user interface and behavior using Cypress is reasonable for this trivial example, but in practice, components are usually not the bulk of the complexity in any given application - it's the domain knowledge and business logic. It can be beneficial, or sometimes essential, to test logic in an isolated fashion. 108 | 109 | Good tests should be reslient to refactors - Cypress tests tend to be, since they test components from a user's point of view - behaviors, not implementation details. 110 | 111 | In general, I like to separate my logic into plain old JavaScript functions, and then wrap the logic using my framework's reactivity primitives (Vue and React have composables and hooks, which are conceptually similar, and Angular uses RxJS). 112 | 113 | Let's separate the logic from the user interface. The logic will go in a separate module, `numbers.ts`: 114 | 115 | ```ts 116 | // numbers.ts 117 | export type Parity = "odd" | "even"; 118 | 119 | export function numbers(parity: Parity): number[] { 120 | const evens: number[] = []; 121 | const odds: number[] = []; 122 | 123 | for (let i = 1; i < 10; i++) { 124 | if (i % 2 === 0) { 125 | evens.push(i); 126 | } else { 127 | odds.push(i); 128 | } 129 | } 130 | 131 | return parity === "odd" ? odds : evens; 132 | } 133 | ``` 134 | 135 | No reactivity or user interface concerns here - it's plain old JavaScript. As a bonus, it's a pure function - easy to test and validate. You could test this with Cypress: 136 | 137 | ```ts 138 | describe("numbers", () => { 139 | it("returns even numbers", () => { 140 | // ... 141 | }); 142 | 143 | it("returns odd numbers", () => { 144 | // ... 145 | }); 146 | }); 147 | ``` 148 | 149 | I tend to use a tool like [Jest](https://jestjs.io) or [Vitest](https://vitest.dev) for these types of tests. I tend to write a lot more tests around business logic, and I like to run those using a terminal based runner. 150 | 151 | Cypress is designed and optimized for testing and debugging by mimicing user interactions such as clicking (`cy.get('button').click()`), typing (`cy.get('input').type('abc')`), etc. The user never directly interacts with the business logic, they interact with the _user interface_, so I don't find Cypress to be a good fit here. 152 | 153 | The `` component is now vastly simplified: 154 | 155 | ```vue 156 | 169 | 170 | 177 | ``` 178 | 179 | The test doesn't need any changes - this is expected, since we haven't actually changed the feature, just made a refactor. If we _did_ need to change the test, that could indicate a code smell - good tests are resilient and should survive a refactor. 180 | 181 | ## Conclusion 182 | 183 | - Use a for loop to express many test cases concisely. 184 | - Test simple computed properties via a user interface test (we use Cypress, but the concept is general). 185 | - Simplify components by isolate business logic from reactivity and framework primitives to be easily testable. 186 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | The examples in this guide use [Vite](https://vitejs.dev), which has templates for React, Vue, and other frameworks. It also uses TypeScript, although this won't impact the vast majority of examples. 4 | 5 | Create a new Vue app by running 6 | 7 | ```sh 8 | npm create vite@latest demo-vue -- --template vue-ts 9 | cd demo-vue 10 | npm install cypress typescript 11 | # optional - adding tailwind 12 | npm install tailwindcss 13 | npx tailwindcss init 14 | ``` 15 | 16 | Open Cypress with `npx cypress open`. You should see 17 | 18 | ![](./images/getting-started-1.png) 19 | 20 | Click "Component Testing", follow the prompts - it will scaffold a few files. Click you favorite browser, and now you are in Cypress! 21 | 22 | ![](./images/getting-started-2.png) 23 | 24 | Head into the next section to start creating components and tests! 25 | -------------------------------------------------------------------------------- /docs/images/getting-started-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/getting-started-1.png -------------------------------------------------------------------------------- /docs/images/getting-started-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/getting-started-2.png -------------------------------------------------------------------------------- /docs/images/input-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/input-1.png -------------------------------------------------------------------------------- /docs/images/input-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/input-2.png -------------------------------------------------------------------------------- /docs/images/pinia-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/pinia-1.png -------------------------------------------------------------------------------- /docs/images/pinia-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/pinia-2.png -------------------------------------------------------------------------------- /docs/images/props.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/props.png -------------------------------------------------------------------------------- /docs/images/router-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/router-1.png -------------------------------------------------------------------------------- /docs/images/router-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/router-2.png -------------------------------------------------------------------------------- /docs/images/router-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/router-3.png -------------------------------------------------------------------------------- /docs/images/router-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/router-4.png -------------------------------------------------------------------------------- /docs/images/vuetify-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/vuetify-1.png -------------------------------------------------------------------------------- /docs/images/vuetify-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/vuetify-2.png -------------------------------------------------------------------------------- /docs/images/vuetify-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/vuetify-3.png -------------------------------------------------------------------------------- /docs/images/vuetify-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmiller1990/cypress-testing-handbook/b61914402610c727144b251c58e5429b1ab91b9a/docs/images/vuetify-4.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome 2 | 3 | Welcome to the Cypress Testing Handbook! This is a guide for testing Vue components using Cypress Component Testing. It's similar to the [Vue Testing Handbook](https://lmiller1990.github.io/vue-testing-handbook/v3/), which I also wrote. 4 | 5 | Cypress uses Vue Test Utils internally, so many of the ideas and concepts are directly appliable. This guide focues on the unique and more powerful features of Cypress, that allow you to write more robust, production-like tests. 6 | 7 | It's still a work in progress. The goal is to include recipes for anything and everything, including integrating and testing third party libraries such as Vuetify, Vuelidate, etc - something that was painful and difficult with Vue Test Utils. 8 | 9 | Ideas and suggestions for content are welcome - [file an issue here](https://github.com/lmiller1990/cypress-testing-handbook/issues). 10 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Welcome to the Cypress Testing Handbook! 4 | 5 | ## What is this? 6 | 7 | A collecting of articles demonstrating how to testing your components (React, Vue etc) with Cypress. The ideas and concepts are by no means unique to Cypress - in general, the articles focus on a given situation, discuss how or what you might want to consider when testing your component, then show some code illustrating how a test might look when written using Cypress. 8 | 9 | ## What Next? 10 | 11 | Let's get started! Head to the next section to set up an example application, and then we jump right into it and start writing some code. 12 | -------------------------------------------------------------------------------- /docs/pinia.md: -------------------------------------------------------------------------------- 1 | # Pinia 2 | 3 | [Pinia](https://pinia.vuejs.org/) is a reactive store for Vue. It's similar to Redux, Vuex, etc. The techniques described here are also applicable to writing tests for components using those libraries. 4 | 5 | The source code for this example can be found [here](https://github.com/lmiller1990/cypress-testing-handbook/tree/main/demo-vue/src/pinia). 6 | 7 | ## Getting Started 8 | 9 | This guide assumes you have created a basic project similar to the one described in [Getting Started](/getting-started). Make sure you have Pinia installed: 10 | 11 | ```sh 12 | npm install pinia 13 | ``` 14 | 15 | There are two ways to define with a Pinia store: 16 | 17 | - [Options Store](https://pinia.vuejs.org/core-concepts/#option-stores) 18 | - [Setup Store](https://pinia.vuejs.org/core-concepts/#setup-stores) 19 | 20 | They work exactly the same. The only difference is how they are declared. Take a look at the links above to see the difference. This guide uses a [Setup Store](https://pinia.vuejs.org/core-concepts/#setup-stores). The primary reason is there is a known limitation for type safety when referencing `this` in getters ([noted here](https://pinia.vuejs.org/core-concepts/getters.html#getters)). I also like that Setup Stores are closer to the usual Composition API function you use in your components. 21 | 22 | Regardless of the semantics, the component code and test code will look exactly the same. 23 | 24 | ## The Component 25 | 26 | I used Cypress to develop this entire component from scratch. It's a simple `` component. Here's how final version looks: 27 | 28 | ![](./images/pinia-1.png) 29 | 30 | ## Configuring Cypress and Pinia 31 | 32 | In a standard Vue app, you install Pinia like this: 33 | 34 | ```ts 35 | import { createApp } from "vue"; 36 | import { createPinia } from "pinia"; 37 | import App from "./App.vue"; 38 | 39 | const pinia = createPinia(); 40 | const app = createApp(App); 41 | 42 | app.use(pinia); 43 | app.mount("#app"); 44 | ``` 45 | 46 | We need to do something similar in Cypress. We could do this on a spec-by-spec basis, but this creates a lot of boilerplate. Instead, we can do something similar to the [Vuetify guide](./vuetify). One key difference is we want a fresh Pinia store for each test. This will give us a clean slate, and ensure our tests are deterministic. 47 | 48 | I like to do this in my `supportFile`, which is `cypress/support/component.ts` by default. All of `supportFile` is executed before each spec runs. 49 | 50 | ```ts 51 | import { createPinia, Pinia, setActivePinia } from "pinia"; 52 | 53 | let pinia: Pinia; 54 | 55 | // Run this code before each *test*. 56 | beforeEach(() => { 57 | // New Pinia 58 | pinia = createPinia(); 59 | 60 | // Set current Pinia instance 61 | setActivePinia(pinia); 62 | }); 63 | ``` 64 | 65 | Next, we need to make sure we install Pinia each time we mount a component, and pass the newly created instance. I like to do this with a custom `cy.mount()` function. In my usual applications, I normally just call this `cy.mount()`. To be explicit, I'll be naming it `cy.mountWithPinia` in this article. 66 | 67 | ```ts {14-35} 68 | import { createPinia, Pinia, setActivePinia } from "pinia"; 69 | 70 | let pinia: Pinia; 71 | 72 | // Run this code before each *test*. 73 | beforeEach(() => { 74 | // New Pinia 75 | pinia = createPinia(); 76 | 77 | // Set current Pinia instance 78 | setActivePinia(pinia); 79 | }); 80 | 81 | function mountWithPinia( 82 | Comp: DefineComponent, 83 | options?: Parameters[1] 84 | ): Cypress.Chainable { 85 | return mount(Comp, { 86 | ...options, 87 | global: { 88 | ...options?.global, 89 | plugins: [...(options?.global?.plugins ?? []), pinia], 90 | }, 91 | }); 92 | } 93 | 94 | declare global { 95 | namespace Cypress { 96 | interface Chainable { 97 | mountWithPinia: typeof mountWithPinia; 98 | } 99 | } 100 | } 101 | 102 | Cypress.Commands.add("mountWithPinia", mountWithPinia); 103 | ``` 104 | 105 | Now we are ready to start developing our `` component! 106 | 107 | ## The Component 108 | 109 | We will start with the fully completed store, and a component rendering each of the todos. 110 | 111 | Let's start with the store. There's a good chunk of code, but it's mostly straight forward. Check the [Pinia docs](https://pinia.vuejs.org/) for more information. Bascally, we: 112 | 113 | - declare some reactive state 114 | - defines some computed values (`completedTodos`, `incompleteTodos`, `filteredTodos`) 115 | - create a method to add a new todo 116 | 117 | ```ts 118 | // store.ts 119 | import { defineStore } from "pinia"; 120 | import { computed, reactive } from "vue"; 121 | 122 | interface Todo { 123 | id: number; 124 | text: string; 125 | completed: boolean; 126 | } 127 | 128 | export const filterTypes = ["all", "completed", "outstanding"] as const; 129 | 130 | interface TodosState { 131 | todos: Todo[]; 132 | filter: (typeof filterTypes)[number]; 133 | nextId: 0; 134 | } 135 | 136 | export const useTodos = defineStore("todos", () => { 137 | const state = reactive({ 138 | todos: [], 139 | filter: "all", 140 | nextId: 0, 141 | }); 142 | 143 | const completedTodos = computed(() => { 144 | return state.todos.filter((todo) => todo.completed); 145 | }); 146 | 147 | const incompleteTodos = computed(() => { 148 | return state.todos.filter((todo) => !todo.completed); 149 | }); 150 | 151 | const filteredTodos = computed(() => { 152 | if (state.filter === "completed") { 153 | return completedTodos.value; 154 | } else if (state.filter === "outstanding") { 155 | return incompleteTodos.value; 156 | } 157 | return state.todos; 158 | }); 159 | 160 | function addTodo(text: string) { 161 | state.todos.push({ text, id: state.nextId++, completed: false }); 162 | } 163 | 164 | return { 165 | state, 166 | filteredTodos, 167 | completedTodos, 168 | incompleteTodos, 169 | addTodo, 170 | }; 171 | }); 172 | ``` 173 | 174 | Finally, a component that uses the `todosStore`. It's self explanatory: 175 | 176 | ```vue 177 | 182 | 183 | 201 | ``` 202 | 203 | ## Testing the Component 204 | 205 | Finally, time to write a test. The `` component is basically a user interface for the `todos` store. While we will write some assertions validating the interface is correctly updated, we will also assert against the state of the store. 206 | 207 | Before even mounting the component, I'll add some todos to the store. We can access the store by doing `useTodos()`, just like you would in a component. 208 | 209 | Once the component is mounted, I'll assert: 210 | 211 | - Current filter is `"all"`, which contains the correct number of todos 212 | - The text for each todo is rendered in a `