├── .gitignore ├── README.md ├── biome.json ├── cosmos.config.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── panda.config.ts ├── pnpm-lock.yaml ├── postcss.config.cjs ├── public ├── next.svg └── vercel.svg ├── setupTests.ts ├── src ├── app │ ├── common │ │ └── components │ │ │ ├── FilterList │ │ │ ├── FilterList.fixture.tsx │ │ │ ├── FilterList.stories.tsx │ │ │ ├── FilterList.test.tsx │ │ │ └── FilterList.tsx │ │ │ ├── SelectWithCombobox │ │ │ ├── README.md │ │ │ ├── SelectWithCombobox.fixture.tsx │ │ │ ├── SelectWithCombobox.stories.tsx │ │ │ ├── SelectWithCombobox.test.tsx │ │ │ └── SelectWithCombobox.tsx │ │ │ ├── Table │ │ │ ├── Table.fixture.tsx │ │ │ └── Table.test.tsx │ │ │ └── ds.ts │ ├── cosmos │ │ ├── Hello.fixture.tsx │ │ ├── README.md │ │ ├── [fixture] │ │ │ ├── cosmos.decorator.tsx │ │ │ └── page.tsx │ │ └── hello.test.tsx │ ├── domains │ │ ├── member │ │ │ └── MemberCreateForm │ │ │ │ ├── MemberCreateForm.fixture.tsx │ │ │ │ ├── MemberCreateForm.stories.tsx │ │ │ │ ├── MemberCreateForm.test.tsx │ │ │ │ ├── MemberCreateForm.tsx │ │ │ │ └── README.md │ │ └── saas │ │ │ └── SaasList │ │ │ ├── README.md │ │ │ ├── SaasList.fixture.tsx │ │ │ ├── SaasList.test.tsx │ │ │ ├── SaasList.tsx │ │ │ ├── SaasListItem.fixture.tsx │ │ │ ├── SaasListItem.test.tsx │ │ │ ├── SaasListItem.tsx │ │ │ ├── expected.png │ │ │ └── unstyled-no-image.png │ ├── favicon.ico │ ├── index.css │ ├── layout.tsx │ ├── page.tsx │ └── routing │ │ └── Link.tsx └── siheom │ ├── expectTL.ts │ ├── getA11ySnapshot.ts │ ├── getTableMarkdown.ts │ ├── queryTL.ts │ └── renderWithContext.tsx ├── tsconfig.json └── vitest.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | cosmos.imports.ts 38 | 39 | ## Panda 40 | styled-system 41 | styled-system-studio -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 테스트와 함께 프론트엔드 개발하기 2 | 3 | 이 레포지토리는 튜링의 사과에서 7월 4일에 진행했던 "테스트와 함께 프론트엔드 개발하기" 강의의 실습 코드입니다. 한 단계 한 단계 신중하게 커밋하면서 작업했기 때문에, 커밋로그만 보셔도 여러모로 도움이 되시리라 생각합니다. 4 | 5 | ## 프로젝트 기술 선정 6 | 7 | 프레임워크 : React + Next.js 8 | 9 | 테스트러너 : vitest + browser mode 10 | 컴포넌트 테스트 : @testing-library/{jest-dom, dom, react, user-event} + siheom 2.0 11 | 스토리북 : react-cosmos 12 | 스타일 : PandaCSS 13 | 폼과 스키마 : react-hook-form + valibot 14 | 헤드리스 : Ariakit 15 | 포맷과 린트 : biome 16 | 17 | ## 배경 18 | 19 | [저](https://x.com/stelo_kim)는 몇 년 동안 프론트엔드 테스트와 접근성, 의존성 주입, 함수형 프로그래밍을 공부하고 실무의 문제를 풀어왔습니다. 테스트나 의존성 주입에 대한 글은 백엔드나 객체지향 위주입니다. 프론트엔드와 함수형 관점에서 접근하는 내용은 희귀하기도 하고 저도 고생이 많았습니다. 리액트 프론트엔드에서는 보통 class를 안 쓰는데, 이런 게 중요하기나 할까요? 20 | 21 | 제가 배운 것들을 공유하고자 글도 여럿 썼고, 작년부터는 발표도 하고 있습니다. 이 자료들만 참고하셔도 꽤 많은 부분을 배우실 수 있을 겁니다. 22 | 23 | - [테스트에서 (더 잘, 더 자주) 배우기](https://tech.wonderwall.kr/articles/learningwithtest/) 24 | - 끔찍한 야근과 뒤통수 맞는 경험을 회고하며, 왜 바쁘다며 테스트를 뒤로 미룰 수록 더 바빠지기만 하는지 그 원인을 탐구합니다. 자동화나 커버리지에 대한 맹신을 비판하고요. 요구사항과 환경 그리고 기술을 이해하고 학습하는 수단으로 테스트를 바라봅니다. 25 | - [우리에게는 프런트 테스트가 필요할지도 모릅니다](https://twinstae.github.io/why-frontend-testing/) 26 | - 함수형이나 프론트엔드에서는 자동화 테스트가 어려울 뿐만 아니라 생산성을 떨어트린다는 편견을 다시 검토합니다. 우리를 힘들게 하는 자동화 테스트가 무엇이고, 우리는 어떤 자동화 테스트가 필요한지 이야기합니다. 27 | - [컴포넌트 테스트 시작하기](https://twinstae.github.io/component-testing-a11y-markup/) 28 | - vitest와 @testing-library를 이용해 심오한 다크 모드 버튼을 만들어보는 과정을 실습해봅니다. role과 accessible name 같은 접근성 개념부터, 마크업, 상태, 효과, SSR에 이르기까지 테스트 하는 법을 배웁니다. 29 | - [접근성이 주도하는 프론트엔드 테스트 자동화](https://tech.wonderwall.kr/articles/a11ydriventestautomation/) 30 | - xpath 나 css 선택자와 같은 기존의 프론트 테스트 방법론이 왜 실패했는지 설명합니다. 웹과 모바일을 막론하고 접근성 API는 UI 테스트를 위한 새로운 표준이 되어가고 있습니다. 특히 WEB의 ARIA 표준을 다루며 실제 업무의 사례와 함께 설명합니다. 31 | - [사용자를 위한 Playwright E2E 테스트](https://tech.wonderwall.kr/articles/playwrighte2etestforuser/) 32 | - 플레이라이트를 이용해 실제 브라우저에서 E2E 테스트를 작성하는 법을 소개합니다. playwright의 lazy한 locator와 web first assertion, auto wait 부터 로그인 인증이나 셋업까지 구체적인 실무 사례와 함께 소개합니다. 33 | - [테스트로 보는 DI - 함수와 변수 추출](https://twinstae.github.io/dependency-injection-extract/) 34 | - 테스트를 하다보면 자연스럽게 의존성 주입(Dependency Injection)에 대해 생각하게 됩니다. 의존성 주입은 테스트 환경 뿐만 아니라 특정 기술이나 특정 환경과의 결합을 끊어줍니다. 하지만 인터넷에 검색하면 늘 스프링 데코레이터와 setter, 생성자 주입 이야기만 하는데, 함수형 프로그래밍의 관점에서 간단한 의존성 주입 기법을 소개합니다. 35 | - [함수형 프론트엔드에서 의존성 제어하기](https://tech.wonderwall.kr/articles/functionaldependencymanagement/) 36 | - 인터페이스와 구현의 차이, 제공자와 사용자, 리팩터링과 파괴적 변경과 같은 의존성 주입의 개념들을 프론트엔드의 구체적인 실무 사례와 함께 설명합니다. 특히 폄하되어 왔던 매개변수 주입, props 주입을 다시 발견하고, context api를 상태관리가 아닌 동적인 의존성 주입 기법으로 보고, module alias를 이용한 정적 의존성 주입 기법도 소개합니다. 37 | - [동료를 당황시키지 않는 순수 함수 적정 기술](https://drive.google.com/file/d/1UxKbwrc4HxdEUqGpZ6M3mKx67NhxmPlo/view?usp=sharing) 38 | - 함수형 컨퍼런스 LiftIO에서 발표했던 자료입니다. 비즈니스 로직을 단순하고 테스트하기 쉬운 순수함수로 만들어 개발하는 법을 텍스트 필드, 가격 포맷, 검색 기능을 구현하는 사례를 통해 다룹니다. 39 | - [스토리북과 컴포넌트 테스트와 함께 하는 ClojureScript React 개발](https://drive.google.com/file/d/1j3dOFjQAx49otvxu77RYG-Nip1TVsIGK/view?usp=sharing) 40 | - 결합도, 수 많은 경우의 수, 느리고 복잡한 테스트의 문제를 스토리북을 이용한 컴포넌트 주도 개발로 풀어내는 법을 소개합니다. 스토리북은 단순히 예쁜 컴포넌트 도감이 아닙니다. 다양한 경우의 수를 쉽고 빠르게 테스트할 수 있는 도구로 새롭게 바라봅니다. 41 | 42 | 생각보다 많지 않습니까? 하지만 테스트의 여정은 아직 끝나지 않았습니다. 43 | 44 | ## 강의에서 다루는 것 45 | 46 | ### 개념과 예시를 넘어서 실무 예제 중심 47 | 48 | 제가 지금까지 공유했던 발표나 글은 시간 관계상, 빠르게 예시만 적용하고 넘어갔습니다. 제 블로그에 있는 컴포넌트 테스트 시작하기도 간단한 다크모드 버튼의 예시만 다뤄서 실무와는 동떨어진 면이 있었고요. 49 | 50 | 이번 강의는 지금까지 말해왔던 여러 방법론과 지식을 실제 실습을 통해 배워볼 수 있게 구성했습니다. 실제로 실무에서 구현했던 요구사항들을 예제로 만들었고. 요구사항을 정제하고, 테스트 -> 기능 구현 -> 리팩토링 -> 테스트 교차 검증... 등으로 이어지는 TDD의 흐름을 직접 보실 수 있습니다. 51 | 52 | ### 브라우저 모드 53 | 54 | 현재 vitest의 베타 기능으로 만들어지고 있는 browser mode를 이용해서, 실제와 유사한 환경에서 e2e보다 훨씬 빠르게 컴포넌트 테스트를 돌릴 수 있습니다. 속도가 전부가 아닙니다. 저 역시 전 직장에서는 주로 jsdom을 이용해 테스트를 했었는데, jsdom 환경과 실제 환경과의 차이로 많은 고통을 겪었습니다. 브라우저 모드로 테스트를 하기 위해서는 풀어야할 문제들도 있기에, 그러한 요령들도 공유합니다. 55 | 56 | ### playwright style의 testing library wrapper siheom 2.0 공개 57 | 58 | 현 직장, 그러니까 셀파스에서 저는 클로저 스크립트로 테스팅 프레임워크인 siheom 3.0을 만들어 사용하고 있습니다. 59 | 60 | 이번 레포지토리를 통해 공개하는 siheom 2.0 은 타입스크립트를 이용했던 전직장에서 playwright의 인터페이스를 testing-library로 유사하게 구현한 래퍼입니다. 중간에 waitForTimeout이나 setTimeout, waitFor 등을 적어줘야 하거나 get과 find, query를 매번 구분해야 했던 @testing-library의 한계를 개선했고, getByRole의 장황한 인터페이스를 간소화했습니다. 61 | 62 | 부족한 부분도 있지만 프로덕션에서 여러 프로젝트에 사용된 만큼 대부분의 경우에 잘 작동합니다. siheom 2.0은 파일 2개일 뿐이라 여러분의 프로젝트에도 복사해서 사용하실 수 있습니다. 63 | 64 | 저 역시 vue를 사용했던 회사에서 일한 적 있기에 react 의존성은 없고, vue나 svelte, solid를 비롯한 다른 라이브러리와도 같이 사용할 수 있습니다. 65 | 66 | ### 웹표준과 헤드리스 컴포넌트 라이브러리를 이용해 나만의 디자인 시스템 구현하기 67 | 68 | 위에서 소개했던 여러 글과 발표에서 헤드리스 컴포넌트 라이브러리와 웹표준 접근성 API를 이용한 개발 방식을 잠깐 짚고 넘어갔었는데요. 69 | 70 | 이번에는 실제로 list와 link, form과 select, combobox 등의 복잡한 컴포넌트들을 시맨틱 html과 제가 실무에서 사용하고 있기도 한 Ariakit 이라는 Headless Component 라이브러리를 이용해서 TDD로 개발하고 스타일링까지 하는 걸 보여드립니다. 실무에서 저는 clojurescript와 reagent를 이용하고 있지만, 예제는 Next js와 react를 이용해 구현했습니다. React가 아닌 vue나 svelte를 이용하시는 분들도 웹표준 html은 동일하게 사용하실 수 있고, radix-vue 나 ark-ui 같은 헤드리스 컴포넌트 라이브러리를 이용하시면 실무에 쉽게 적용하실 수 있으리라 기대합니다. 71 | 72 | ### 강의 교안 73 | 74 | - [하나. 브라우저 컴포넌트 테스트 환경 셋업하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/cosmos) 75 | - [둘. 목록과 필터 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/saas/SaasList) 76 | - [셋. 폼 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/member/MemberCreateForm) 77 | - [넷. 헤드리스 컴포넌트로 만든 디자인 시스템 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/common/components/SelectWithCombobox) 78 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | }, 11 | "ignore": [".next", "cosmos.imports.ts", "styled-system"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /cosmos.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rendererUrl": { 3 | "dev": "http://localhost:3000/cosmos/", 4 | "export": "/cosmos/.html" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turing-frontend-test", 3 | "version": "0.1.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "turing-frontend-test", 9 | "version": "0.1.0", 10 | "dependencies": { 11 | "next": "14.2.4", 12 | "react": "^18", 13 | "react-dom": "^18" 14 | }, 15 | "devDependencies": { 16 | "@types/node": "^20", 17 | "@types/react": "^18", 18 | "@types/react-dom": "^18", 19 | "typescript": "^5" 20 | } 21 | }, 22 | "node_modules/@next/env": { 23 | "version": "14.2.4", 24 | "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz", 25 | "integrity": "sha512-3EtkY5VDkuV2+lNmKlbkibIJxcO4oIHEhBWne6PaAp+76J9KoSsGvNikp6ivzAT8dhhBMYrm6op2pS1ApG0Hzg==" 26 | }, 27 | "node_modules/@next/swc-darwin-arm64": { 28 | "version": "14.2.4", 29 | "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.4.tgz", 30 | "integrity": "sha512-AH3mO4JlFUqsYcwFUHb1wAKlebHU/Hv2u2kb1pAuRanDZ7pD/A/KPD98RHZmwsJpdHQwfEc/06mgpSzwrJYnNg==", 31 | "cpu": [ 32 | "arm64" 33 | ], 34 | "optional": true, 35 | "os": [ 36 | "darwin" 37 | ], 38 | "engines": { 39 | "node": ">= 10" 40 | } 41 | }, 42 | "node_modules/@next/swc-darwin-x64": { 43 | "version": "14.2.4", 44 | "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.4.tgz", 45 | "integrity": "sha512-QVadW73sWIO6E2VroyUjuAxhWLZWEpiFqHdZdoQ/AMpN9YWGuHV8t2rChr0ahy+irKX5mlDU7OY68k3n4tAZTg==", 46 | "cpu": [ 47 | "x64" 48 | ], 49 | "optional": true, 50 | "os": [ 51 | "darwin" 52 | ], 53 | "engines": { 54 | "node": ">= 10" 55 | } 56 | }, 57 | "node_modules/@next/swc-linux-arm64-gnu": { 58 | "version": "14.2.4", 59 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.4.tgz", 60 | "integrity": "sha512-KT6GUrb3oyCfcfJ+WliXuJnD6pCpZiosx2X3k66HLR+DMoilRb76LpWPGb4tZprawTtcnyrv75ElD6VncVamUQ==", 61 | "cpu": [ 62 | "arm64" 63 | ], 64 | "optional": true, 65 | "os": [ 66 | "linux" 67 | ], 68 | "engines": { 69 | "node": ">= 10" 70 | } 71 | }, 72 | "node_modules/@next/swc-linux-arm64-musl": { 73 | "version": "14.2.4", 74 | "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.4.tgz", 75 | "integrity": "sha512-Alv8/XGSs/ytwQcbCHwze1HmiIkIVhDHYLjczSVrf0Wi2MvKn/blt7+S6FJitj3yTlMwMxII1gIJ9WepI4aZ/A==", 76 | "cpu": [ 77 | "arm64" 78 | ], 79 | "optional": true, 80 | "os": [ 81 | "linux" 82 | ], 83 | "engines": { 84 | "node": ">= 10" 85 | } 86 | }, 87 | "node_modules/@next/swc-linux-x64-gnu": { 88 | "version": "14.2.4", 89 | "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.4.tgz", 90 | "integrity": "sha512-ze0ShQDBPCqxLImzw4sCdfnB3lRmN3qGMB2GWDRlq5Wqy4G36pxtNOo2usu/Nm9+V2Rh/QQnrRc2l94kYFXO6Q==", 91 | "cpu": [ 92 | "x64" 93 | ], 94 | "optional": true, 95 | "os": [ 96 | "linux" 97 | ], 98 | "engines": { 99 | "node": ">= 10" 100 | } 101 | }, 102 | "node_modules/@next/swc-linux-x64-musl": { 103 | "version": "14.2.4", 104 | "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.4.tgz", 105 | "integrity": "sha512-8dwC0UJoc6fC7PX70csdaznVMNr16hQrTDAMPvLPloazlcaWfdPogq+UpZX6Drqb1OBlwowz8iG7WR0Tzk/diQ==", 106 | "cpu": [ 107 | "x64" 108 | ], 109 | "optional": true, 110 | "os": [ 111 | "linux" 112 | ], 113 | "engines": { 114 | "node": ">= 10" 115 | } 116 | }, 117 | "node_modules/@next/swc-win32-arm64-msvc": { 118 | "version": "14.2.4", 119 | "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.4.tgz", 120 | "integrity": "sha512-jxyg67NbEWkDyvM+O8UDbPAyYRZqGLQDTPwvrBBeOSyVWW/jFQkQKQ70JDqDSYg1ZDdl+E3nkbFbq8xM8E9x8A==", 121 | "cpu": [ 122 | "arm64" 123 | ], 124 | "optional": true, 125 | "os": [ 126 | "win32" 127 | ], 128 | "engines": { 129 | "node": ">= 10" 130 | } 131 | }, 132 | "node_modules/@next/swc-win32-ia32-msvc": { 133 | "version": "14.2.4", 134 | "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.4.tgz", 135 | "integrity": "sha512-twrmN753hjXRdcrZmZttb/m5xaCBFa48Dt3FbeEItpJArxriYDunWxJn+QFXdJ3hPkm4u7CKxncVvnmgQMY1ag==", 136 | "cpu": [ 137 | "ia32" 138 | ], 139 | "optional": true, 140 | "os": [ 141 | "win32" 142 | ], 143 | "engines": { 144 | "node": ">= 10" 145 | } 146 | }, 147 | "node_modules/@next/swc-win32-x64-msvc": { 148 | "version": "14.2.4", 149 | "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.4.tgz", 150 | "integrity": "sha512-tkLrjBzqFTP8DVrAAQmZelEahfR9OxWpFR++vAI9FBhCiIxtwHwBHC23SBHCTURBtwB4kc/x44imVOnkKGNVGg==", 151 | "cpu": [ 152 | "x64" 153 | ], 154 | "optional": true, 155 | "os": [ 156 | "win32" 157 | ], 158 | "engines": { 159 | "node": ">= 10" 160 | } 161 | }, 162 | "node_modules/@swc/counter": { 163 | "version": "0.1.3", 164 | "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", 165 | "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" 166 | }, 167 | "node_modules/@swc/helpers": { 168 | "version": "0.5.5", 169 | "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", 170 | "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", 171 | "dependencies": { 172 | "@swc/counter": "^0.1.3", 173 | "tslib": "^2.4.0" 174 | } 175 | }, 176 | "node_modules/@types/node": { 177 | "version": "20.14.9", 178 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", 179 | "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", 180 | "dev": true, 181 | "dependencies": { 182 | "undici-types": "~5.26.4" 183 | } 184 | }, 185 | "node_modules/@types/prop-types": { 186 | "version": "15.7.12", 187 | "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", 188 | "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", 189 | "dev": true 190 | }, 191 | "node_modules/@types/react": { 192 | "version": "18.3.3", 193 | "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", 194 | "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", 195 | "dev": true, 196 | "dependencies": { 197 | "@types/prop-types": "*", 198 | "csstype": "^3.0.2" 199 | } 200 | }, 201 | "node_modules/@types/react-dom": { 202 | "version": "18.3.0", 203 | "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", 204 | "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", 205 | "dev": true, 206 | "dependencies": { 207 | "@types/react": "*" 208 | } 209 | }, 210 | "node_modules/busboy": { 211 | "version": "1.6.0", 212 | "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", 213 | "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", 214 | "dependencies": { 215 | "streamsearch": "^1.1.0" 216 | }, 217 | "engines": { 218 | "node": ">=10.16.0" 219 | } 220 | }, 221 | "node_modules/caniuse-lite": { 222 | "version": "1.0.30001638", 223 | "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001638.tgz", 224 | "integrity": "sha512-5SuJUJ7cZnhPpeLHaH0c/HPAnAHZvS6ElWyHK9GSIbVOQABLzowiI2pjmpvZ1WEbkyz46iFd4UXlOHR5SqgfMQ==", 225 | "funding": [ 226 | { 227 | "type": "opencollective", 228 | "url": "https://opencollective.com/browserslist" 229 | }, 230 | { 231 | "type": "tidelift", 232 | "url": "https://tidelift.com/funding/github/npm/caniuse-lite" 233 | }, 234 | { 235 | "type": "github", 236 | "url": "https://github.com/sponsors/ai" 237 | } 238 | ] 239 | }, 240 | "node_modules/client-only": { 241 | "version": "0.0.1", 242 | "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", 243 | "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" 244 | }, 245 | "node_modules/csstype": { 246 | "version": "3.1.3", 247 | "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", 248 | "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", 249 | "dev": true 250 | }, 251 | "node_modules/graceful-fs": { 252 | "version": "4.2.11", 253 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", 254 | "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" 255 | }, 256 | "node_modules/js-tokens": { 257 | "version": "4.0.0", 258 | "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", 259 | "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 260 | }, 261 | "node_modules/loose-envify": { 262 | "version": "1.4.0", 263 | "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", 264 | "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", 265 | "dependencies": { 266 | "js-tokens": "^3.0.0 || ^4.0.0" 267 | }, 268 | "bin": { 269 | "loose-envify": "cli.js" 270 | } 271 | }, 272 | "node_modules/nanoid": { 273 | "version": "3.3.7", 274 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", 275 | "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", 276 | "funding": [ 277 | { 278 | "type": "github", 279 | "url": "https://github.com/sponsors/ai" 280 | } 281 | ], 282 | "bin": { 283 | "nanoid": "bin/nanoid.cjs" 284 | }, 285 | "engines": { 286 | "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 287 | } 288 | }, 289 | "node_modules/next": { 290 | "version": "14.2.4", 291 | "resolved": "https://registry.npmjs.org/next/-/next-14.2.4.tgz", 292 | "integrity": "sha512-R8/V7vugY+822rsQGQCjoLhMuC9oFj9SOi4Cl4b2wjDrseD0LRZ10W7R6Czo4w9ZznVSshKjuIomsRjvm9EKJQ==", 293 | "dependencies": { 294 | "@next/env": "14.2.4", 295 | "@swc/helpers": "0.5.5", 296 | "busboy": "1.6.0", 297 | "caniuse-lite": "^1.0.30001579", 298 | "graceful-fs": "^4.2.11", 299 | "postcss": "8.4.31", 300 | "styled-jsx": "5.1.1" 301 | }, 302 | "bin": { 303 | "next": "dist/bin/next" 304 | }, 305 | "engines": { 306 | "node": ">=18.17.0" 307 | }, 308 | "optionalDependencies": { 309 | "@next/swc-darwin-arm64": "14.2.4", 310 | "@next/swc-darwin-x64": "14.2.4", 311 | "@next/swc-linux-arm64-gnu": "14.2.4", 312 | "@next/swc-linux-arm64-musl": "14.2.4", 313 | "@next/swc-linux-x64-gnu": "14.2.4", 314 | "@next/swc-linux-x64-musl": "14.2.4", 315 | "@next/swc-win32-arm64-msvc": "14.2.4", 316 | "@next/swc-win32-ia32-msvc": "14.2.4", 317 | "@next/swc-win32-x64-msvc": "14.2.4" 318 | }, 319 | "peerDependencies": { 320 | "@opentelemetry/api": "^1.1.0", 321 | "@playwright/test": "^1.41.2", 322 | "react": "^18.2.0", 323 | "react-dom": "^18.2.0", 324 | "sass": "^1.3.0" 325 | }, 326 | "peerDependenciesMeta": { 327 | "@opentelemetry/api": { 328 | "optional": true 329 | }, 330 | "@playwright/test": { 331 | "optional": true 332 | }, 333 | "sass": { 334 | "optional": true 335 | } 336 | } 337 | }, 338 | "node_modules/picocolors": { 339 | "version": "1.0.1", 340 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", 341 | "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" 342 | }, 343 | "node_modules/postcss": { 344 | "version": "8.4.31", 345 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", 346 | "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", 347 | "funding": [ 348 | { 349 | "type": "opencollective", 350 | "url": "https://opencollective.com/postcss/" 351 | }, 352 | { 353 | "type": "tidelift", 354 | "url": "https://tidelift.com/funding/github/npm/postcss" 355 | }, 356 | { 357 | "type": "github", 358 | "url": "https://github.com/sponsors/ai" 359 | } 360 | ], 361 | "dependencies": { 362 | "nanoid": "^3.3.6", 363 | "picocolors": "^1.0.0", 364 | "source-map-js": "^1.0.2" 365 | }, 366 | "engines": { 367 | "node": "^10 || ^12 || >=14" 368 | } 369 | }, 370 | "node_modules/react": { 371 | "version": "18.3.1", 372 | "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", 373 | "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", 374 | "dependencies": { 375 | "loose-envify": "^1.1.0" 376 | }, 377 | "engines": { 378 | "node": ">=0.10.0" 379 | } 380 | }, 381 | "node_modules/react-dom": { 382 | "version": "18.3.1", 383 | "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", 384 | "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", 385 | "dependencies": { 386 | "loose-envify": "^1.1.0", 387 | "scheduler": "^0.23.2" 388 | }, 389 | "peerDependencies": { 390 | "react": "^18.3.1" 391 | } 392 | }, 393 | "node_modules/scheduler": { 394 | "version": "0.23.2", 395 | "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", 396 | "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", 397 | "dependencies": { 398 | "loose-envify": "^1.1.0" 399 | } 400 | }, 401 | "node_modules/source-map-js": { 402 | "version": "1.2.0", 403 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", 404 | "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", 405 | "engines": { 406 | "node": ">=0.10.0" 407 | } 408 | }, 409 | "node_modules/streamsearch": { 410 | "version": "1.1.0", 411 | "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", 412 | "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", 413 | "engines": { 414 | "node": ">=10.0.0" 415 | } 416 | }, 417 | "node_modules/styled-jsx": { 418 | "version": "5.1.1", 419 | "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", 420 | "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", 421 | "dependencies": { 422 | "client-only": "0.0.1" 423 | }, 424 | "engines": { 425 | "node": ">= 12.0.0" 426 | }, 427 | "peerDependencies": { 428 | "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" 429 | }, 430 | "peerDependenciesMeta": { 431 | "@babel/core": { 432 | "optional": true 433 | }, 434 | "babel-plugin-macros": { 435 | "optional": true 436 | } 437 | } 438 | }, 439 | "node_modules/tslib": { 440 | "version": "2.6.3", 441 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", 442 | "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" 443 | }, 444 | "node_modules/typescript": { 445 | "version": "5.5.2", 446 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz", 447 | "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==", 448 | "dev": true, 449 | "bin": { 450 | "tsc": "bin/tsc", 451 | "tsserver": "bin/tsserver" 452 | }, 453 | "engines": { 454 | "node": ">=14.17" 455 | } 456 | }, 457 | "node_modules/undici-types": { 458 | "version": "5.26.5", 459 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", 460 | "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", 461 | "dev": true 462 | } 463 | } 464 | } 465 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turing-frontend-test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "prepare": "panda codegen", 8 | "dev": "next dev --turbo", 9 | "build": "next build", 10 | "start": "next start", 11 | "format": "pnpm biome format --write src/**/*", 12 | "lint": "biome lint", 13 | "story": "cosmos --expose-imports", 14 | "cosmos-export": "cosmos-export --expose-imports", 15 | "test": "vitest", 16 | "test:headed": "vitest --browser.headless false", 17 | "test:node": "vitest --browser.enabled false", 18 | "test:cov": "vitest run --coverage.enabled true" 19 | }, 20 | "dependencies": { 21 | "@ariakit/react": "^0.4.7", 22 | "@hookform/resolvers": "^3.6.0", 23 | "korean-regexp": "^1.0.12", 24 | "next": "14.2.4", 25 | "overlay-kit": "^1.0.0", 26 | "react": "^18", 27 | "react-dom": "^18", 28 | "react-hook-form": "^7.52.0", 29 | "react-toastify": "^10.0.5", 30 | "tiny-invariant": "^1.3.3", 31 | "valibot": "^0.35.0" 32 | }, 33 | "devDependencies": { 34 | "@biomejs/biome": "1.8.3", 35 | "@pandacss/dev": "^0.41.0", 36 | "@testing-library/dom": "^10.2.0", 37 | "@testing-library/jest-dom": "^6.4.6", 38 | "@testing-library/react": "^16.0.0", 39 | "@testing-library/user-event": "^14.5.2", 40 | "@types/node": "^20", 41 | "@types/react": "^18", 42 | "@types/react-dom": "^18", 43 | "@vitejs/plugin-react": "^4.3.1", 44 | "@vitest/browser": "^2.0.0-beta.12", 45 | "@vitest/coverage-istanbul": "^2.0.0-beta.12", 46 | "@vitest/ui": "^2.0.0-beta.12", 47 | "happy-dom": "^14.12.3", 48 | "playwright": "^1.45.0", 49 | "react-cosmos": "^6.1.1", 50 | "react-cosmos-next": "^6.1.1", 51 | "typescript": "^5", 52 | "vite-plugin-svgr": "^4.2.0", 53 | "vite-tsconfig-paths": "^4.3.2", 54 | "vitest": "^2.0.0-beta.12" 55 | } 56 | } -------------------------------------------------------------------------------- /panda.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@pandacss/dev"; 2 | 3 | export default defineConfig({ 4 | presets: ["@pandacss/preset-base"], 5 | // Whether to use css reset 6 | preflight: true, 7 | 8 | // Where to look for your css declarations 9 | include: ["./src/app/**/*.{ts,tsx}"], 10 | 11 | // Files to exclude 12 | exclude: [], 13 | 14 | // Useful for theme customization 15 | theme: { 16 | extend: {}, 17 | }, 18 | 19 | // The output directory for your css system 20 | outdir: "styled-system", 21 | jsxFramework: "react", 22 | jsxStyleProps: 'none' 23 | }); 24 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@pandacss/dev/postcss': {}, 4 | }, 5 | } -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import "@/app/index.css" 2 | import "@testing-library/jest-dom/vitest"; 3 | import { cleanup } from "@testing-library/react"; 4 | import { afterEach } from "vitest"; 5 | 6 | afterEach(() => { 7 | cleanup(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/common/components/FilterList/FilterList.fixture.tsx: -------------------------------------------------------------------------------- 1 | import { FilterListStory } from "./FilterList.stories"; 2 | 3 | export default { 4 | "여러 개의 필터": , 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/common/components/FilterList/FilterList.stories.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import { FilterList } from "./FilterList"; 4 | 5 | export const FilterListStory = () => { 6 | const [selected, setSelected] = useState("all"); 7 | return ( 8 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/app/common/components/FilterList/FilterList.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import FilterListStories from "./FilterList.fixture"; 3 | import { describe, test } from "vitest"; 4 | import { expectTL } from "@/siheom/expectTL"; 5 | import { queryTL } from "@/siheom/queryTL"; 6 | 7 | describe("FilterList", () => { 8 | test("결제 내역 있는 SaaS만 필터할 수 있다", async () => { 9 | render(FilterListStories["여러 개의 필터"]); 10 | 11 | await expectTL(queryTL.radio("전체")).toBeChecked(); 12 | await expectTL(queryTL.radio("결제 내역 있는 SaaS")).not.toBeChecked(); 13 | 14 | await queryTL.radio("결제 내역 있는 SaaS").click(); 15 | 16 | await expectTL(queryTL.radio("전체")).not.toBeChecked(); 17 | await expectTL(queryTL.radio("결제 내역 있는 SaaS")).toBeChecked(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/app/common/components/FilterList/FilterList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { css } from "@styled-system/css/css"; 3 | import { hstack } from "@styled-system/patterns"; 4 | import type { ReactNode } from "react"; 5 | 6 | export const FilterList = ({ 7 | title, 8 | optionList, 9 | selected, 10 | onChange, 11 | }: { 12 | title: string; 13 | optionList: { value: string; label: ReactNode }[]; 14 | selected: string; 15 | onChange: (value: string) => void; 16 | }) => { 17 | return ( 18 |
19 | {title} 20 | {optionList.map((option) => ( 21 | 49 | ))} 50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/app/common/components/SelectWithCombobox/README.md: -------------------------------------------------------------------------------- 1 | ## 헤드리스 컴포넌트로 구현한 복잡한 컴포넌트 테스트하기 2 | 3 | ### 요구사항 파악하기 4 | 5 | ### 헤드리스 컴포넌트로 접근성 표준을 배우기 6 | 7 | ### 헤드리스 컴포넌트를 사용해서 구현하기 8 | 9 | ### 우리 요구사항에 맞게 변형하기 10 | 11 | ### dialog 테스트하기 12 | 13 | ### 테스트를 믿고 리팩토링 해보기 toss overlay 14 | 15 | ### 목차 16 | 17 | - [하나. 브라우저 컴포넌트 테스트 환경 셋업하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/cosmos) 18 | - [둘. 목록과 필터 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/saas/SaasList) 19 | - [셋. 폼 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/member/MemberCreateForm) 20 | - [넷. 헤드리스 컴포넌트로 만든 디자인 시스템 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/common/components/SelectWithCombobox) <- 지금 여기 21 | -------------------------------------------------------------------------------- /src/app/common/components/SelectWithCombobox/SelectWithCombobox.fixture.tsx: -------------------------------------------------------------------------------- 1 | import { SelectWithComboboxStory } from "./SelectWithCombobox.stories"; 2 | 3 | const testMemberList = [ 4 | { id: "4c96d018-51d7-4ac1-9845-0b47ab68a70e", name: "탐정토끼" }, 5 | { id: "4006ffc5-96dd-46f6-82ce-da454b457ac8", name: "김태희" }, 6 | { id: "8d9b5007-b5f9-49be-914e-972814309876", name: "stelo" }, 7 | ]; 8 | 9 | export default { 10 | "여러 개의 옵션": , 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/common/components/SelectWithCombobox/SelectWithCombobox.stories.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import { SelectWithCombobox } from "./SelectWithCombobox"; 4 | import { OverlayProvider, overlay } from "overlay-kit"; 5 | import { Dialog, DialogDismiss, DialogHeading } from "@ariakit/react/dialog"; 6 | import { button, dialog } from "../ds"; 7 | import { css } from "@styled-system/css/css"; 8 | 9 | export const SelectWithComboboxStory = ({ 10 | memberList, 11 | }: { memberList: { id: string; name: string }[] }) => { 12 | const [selected, setSelected] = useState(null); 13 | 14 | return ( 15 | 16 | 설정하기} 19 | removeLabel={해제하기} 20 | searchPlaceholder="이름을 입력해주세요" 21 | optionList={memberList.map((member) => ({ 22 | value: member.id, 23 | searchValue: member.name, 24 | label:
{member.name}
, 25 | }))} 26 | selected={selected} 27 | onChange={(newOwner) => { 28 | if (newOwner === null) { 29 | overlay.open(({ isOpen, close }) => { 30 | return ( 31 | 32 | 사용자를 해제할까요? 33 |
34 | { 37 | setSelected(null); 38 | }} 39 | > 40 | 확인 41 | 42 |
43 |
44 | ); 45 | }); 46 | } else { 47 | setSelected(newOwner); 48 | } 49 | }} 50 | /> 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/app/common/components/SelectWithCombobox/SelectWithCombobox.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import SelectWithComboboxStories from "./SelectWithCombobox.fixture"; 3 | import { describe, expect, test } from "vitest"; 4 | import { expectTL } from "@/siheom/expectTL"; 5 | import { queryTL } from "@/siheom/queryTL"; 6 | import { getA11ySnapshot } from "@/siheom/getA11ySnapshot"; 7 | 8 | describe("SelectWithCombobox", () => { 9 | test("옵션을 검색하고 선택하고 취소할 수 있다", async () => { 10 | render(SelectWithComboboxStories["여러 개의 옵션"]); 11 | 12 | await expectTL(queryTL.combobox("사용자")).toHaveText("설정하기"); 13 | 14 | await queryTL.combobox("사용자").click(); 15 | 16 | await expectTL(queryTL.option("")).toHaveTextContents([ 17 | "탐정토끼", 18 | "김태희", 19 | "stelo", 20 | ]); 21 | 22 | expect(getA11ySnapshot(document.body)).toMatchInlineSnapshot(` 23 | "combobox: 사용자 24 | dialog: 사용자 25 | combobox: 이름을 입력해주세요 26 | listbox 27 | option: 탐정토끼 28 | option: 김태희 29 | option: stelo" 30 | `); 31 | 32 | await queryTL.combobox("이름을 입력해주세요").fill("ㅌ"); 33 | 34 | await expectTL(queryTL.option("")).toHaveTextContents([ 35 | "탐정토끼", 36 | "김태희", 37 | ]); 38 | 39 | await queryTL.combobox("이름을 입력해주세요").fill("김"); 40 | 41 | await expectTL(queryTL.option("")).toHaveTextContents(["김태희"]); 42 | 43 | await queryTL.option("김태희").click(); 44 | 45 | await queryTL.button("김태희 해제하기").click(); 46 | 47 | await expectTL(queryTL.dialog("사용자를 해제할까요?")).toBeVisible(); 48 | 49 | expect(getA11ySnapshot(document.body)).toMatchInlineSnapshot(` 50 | "button: 김태희 해제하기 51 | dialog: 사용자를 해제할까요? 52 | heading: 사용자를 해제할까요? 53 | button: 확인" 54 | `); 55 | 56 | await queryTL.dialog("사용자를 해제할까요?").button("확인").click(); 57 | 58 | await expectTL(queryTL.combobox("사용자")).toHaveText("설정하기"); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/app/common/components/SelectWithCombobox/SelectWithCombobox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import * as Ariakit from "@ariakit/react"; 3 | import { cx } from "@styled-system/css"; 4 | import { css } from "@styled-system/css/css"; 5 | import { hstack, vstack } from "@styled-system/patterns"; 6 | import { getRegExp } from "korean-regexp"; 7 | import { startTransition, useState, type ReactNode } from "react"; 8 | import invariant from "tiny-invariant"; 9 | 10 | export const SelectWithCombobox = ({ 11 | label, 12 | emptyValue, 13 | removeLabel, 14 | searchPlaceholder, 15 | optionList, 16 | selected, 17 | onChange, 18 | }: { 19 | label: ReactNode; 20 | emptyValue: ReactNode; 21 | removeLabel: ReactNode; 22 | optionList: { value: string; searchValue: string; label: ReactNode }[]; 23 | searchPlaceholder: string; 24 | selected: string | null; 25 | onChange: (value: string | null) => void; 26 | }) => { 27 | const [searchValue, setSearchValue] = useState(""); 28 | 29 | const regex = getRegExp(searchValue); 30 | const matches = optionList.filter((option) => regex.test(option.searchValue)); 31 | 32 | const selectedOption = selected 33 | ? optionList.find((option) => option.value === selected) 34 | : null; 35 | 36 | invariant(selectedOption !== undefined); 37 | 38 | const inputRecipe = () => 39 | css({ 40 | width: "100%", 41 | borderRadius: "8px", 42 | border: "1px solid #d9dee2", 43 | padding: "16px", 44 | textAlign: "left", 45 | }); 46 | 47 | return ( 48 | { 51 | startTransition(() => { 52 | setSearchValue(value); 53 | }); 54 | }} 55 | > 56 | { 58 | onChange(value as string | null); 59 | }} 60 | defaultValue="" 61 | > 62 | {label} 63 | {selectedOption ? ( 64 | 73 | ) : ( 74 | 75 | {() => emptyValue} 76 | 77 | )} 78 | 91 | 97 | 98 | {matches.map((option) => ( 99 | {option.label} 112 | } 113 | /> 114 | ))} 115 | 116 | 117 | 118 | 119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /src/app/common/components/Table/Table.fixture.tsx: -------------------------------------------------------------------------------- 1 | export default { 2 | 예시: ( 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
7 | NameJobFavorite Color
1Cy GandertonQuality Control SpecialistBlue
2Hart HagertyDesktop Support TechnicianPurple
3Brice SwyreTax AccountantRed
35 | ), 36 | }; 37 | -------------------------------------------------------------------------------- /src/app/common/components/Table/Table.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import TableFixtures from "./Table.fixture"; 3 | import { describe, expect, test } from "vitest"; 4 | import { htmlTableToMarkdown } from "@/siheom/getTableMarkdown"; 5 | 6 | describe("Table", () => { 7 | test("옵션을 검색하고 선택하고 취소할 수 있다", async () => { 8 | render(TableFixtures.예시); 9 | 10 | expect( 11 | htmlTableToMarkdown(screen.getByRole("table")), 12 | ).toMatchInlineSnapshot(` 13 | "| | Name | Job | Favorite Color | 14 | | - | ------------ | -------------------------- | -------------- | 15 | | 1 | Cy Ganderton | Quality Control Specialist | Blue | 16 | | 2 | Hart Hagerty | Desktop Support Technician | Purple | 17 | | 3 | Brice Swyre | Tax Accountant | Red | 18 | " 19 | `); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/common/components/ds.ts: -------------------------------------------------------------------------------- 1 | import { css } from "@styled-system/css/css"; 2 | 3 | export const button = () => 4 | css({ 5 | background: "#4f89fb", 6 | color: "white", 7 | width: "100%", 8 | borderRadius: "8px", 9 | display: "flex", 10 | gap: "8px", 11 | justifyContent: "center", 12 | padding: "14px 16px", 13 | fontSize: "17px", 14 | fontWeight: 600, 15 | transitionProperty: "background, color", 16 | transitionDuration: ".125s", 17 | transitionTimingFunction: "ease-in-out", 18 | _hover: { 19 | background: "#1863f6", 20 | }, 21 | }); 22 | 23 | export const dialog = () => 24 | css({ 25 | background: "white", 26 | borderRadius: "16px", 27 | boxShadow: "0 12px 40px -4px rgba(25,30,40,.16)", 28 | position: "fixed", 29 | inset: "0.75rem", 30 | zIndex: 50, 31 | margin: "auto", 32 | display: "flex", 33 | height: "fit-content", 34 | maxHeight: "calc(100dvh - 2 * 0.75rem)", 35 | flexDirection: "column", 36 | gap: "1rem", 37 | overflow: "auto", 38 | backgroundColor: "white", 39 | padding: "1rem", 40 | color: "black", 41 | maxWidth: "420px", 42 | }); 43 | -------------------------------------------------------------------------------- /src/app/cosmos/Hello.fixture.tsx: -------------------------------------------------------------------------------- 1 | export default

Hello World!

; 2 | -------------------------------------------------------------------------------- /src/app/cosmos/README.md: -------------------------------------------------------------------------------- 1 | ## 컴포넌트 테스트 시작하기 2 | 3 | 테스트를 하는 사람이 아니라도, 누구나 셋업을 하고 나면 간단한 코드로 모든 게 잘 돌아가는지 확인합니다. 4 | 5 | 템플릿을 클론하는 게 편리하긴 합니다만. 이미 기존에 존재하는 프로젝트에 테스트 환경을 추가하는 경우가 더 많을 겁니다. 운영체제도 다르고, 프레임워크도 다르고, 이런저런 다른 세팅이 뒤섞이면 셋업에만 하루가 넘게 걸리기도 합니다. 6 | 7 | 제 커밋 로그를 보시면 아시겠지만. 저는 작은 단계를 밟아가면서 하나씩 셋업을 하고, 각 단계가 잘 되는지 확인합니다. 한 번에 몰아서 우르르 확인하면, 어디서 잘못된 건지 찾기 힘드니까요. 8 | 9 | ### Initial commit from Create Next App 10 | 11 | 일단 `pnpm install` -> `pnpm dev` 로 서버가 잘 뜨는지부터 확인합니다. 12 | 13 | ### react-cosmos storybook setting 14 | 15 | 테스트할 컴포넌트를 관리하고, 눈으로 확인해보고 직접 눌러볼 수 있고, 페이지와 격리해서 렌더할 수 있게 스토리북을 사용하려 합니다. 널리 쓰이는 `storybook`도 좋지만, 여기서는 여러 이유로 `react-cosmos`를 사용하려 합니다. [ladle](https://ladle.dev/blog/introducing-ladle/) 같은 대체제도 살펴보세요. 16 | 17 | ```sh 18 | pnpm add -D react-cosmos 19 | ``` 20 | 21 | `cosmos.config.json`, `cosmos/[fixture]/page.tsx` 파일 등을 만듭니다. package.json에 script를 추가하고 `pnpm story` 로 잘 보이는지 확인합니다. 22 | 23 | ### setup vitest 24 | 25 | unit, component test를 위해서 vitest를 설치합니다. vitest는 성능도 성능이지만, 브라우저 모드와 UI 같은 여러 강력한 기능이 많고, 셋업하기도 jest보다 쉽습니다. jest와 api도 호환되고요. 26 | 27 | ```sh 28 | pnpm add -D vitest @vitejs/plugin-react 29 | ``` 30 | 31 | `vitest.config.ts`와 `setupTests.ts` 그리고 간단한 `hello.test.tsx` 파일을 만듭니다. 32 | 33 | ```ts 34 | // vitest.config.ts 35 | import { defineConfig } from 'vitest/config'; 36 | import react from '@vitejs/plugin-react'; 37 | /// 38 | 39 | export default defineConfig({ 40 | plugins: [react()], 41 | root: './', 42 | test: { 43 | setupFiles: './setupTests.ts', 44 | include: ['src/**/*.test.tsx', 'src/**/*.test.ts'], 45 | css: true, 46 | pool: 'vmThreads', 47 | poolOptions: { 48 | useAtomics: true 49 | }, 50 | testTimeout: 3000 51 | }, 52 | }); 53 | 54 | ``` 55 | 56 | setupTests.ts 에는 지금은 별 내용이 없습니다. 혹시 앱에서 사용하는 전역 css 파일이 있으면 똑같이 import해줍시다. css를 import하지 않으면 나중에 브라우저에서 테스트를 돌릴 때 css가 적용되지 않는 것처럼 보입니다. 57 | 58 | ```ts 59 | // setupTests.ts 60 | import "@/app/index.css" 61 | ``` 62 | 63 | 역사와 전통을 따라서 간단하게 1+1을 검증하는 테스트를 만듭니다. 64 | 65 | ```ts 66 | // hello.test.tsx 67 | import { expect, test } from 'vitest' 68 | 69 | test('adds 1 + 2 to equal 3', () => { 70 | expect(1 + 2).toBe(3) 71 | }) 72 | ``` 73 | 74 | 물론 테스트가 잘 실패합니다! 3을 2로 바꾸면 테스트가 성공하는 것도 볼 수 있습니다. 75 | 76 | ### setup vitest browser mode 77 | 78 | node에서 테스트를 돌리는 것도 좋지만, node에는 dom 뿐만 아니라 여러 브라우저 API가 없기 때문에 실제 브라우저에서 테스트하는 쪽으로 옮겨가는 추세입니다. vitest도 playwright나 webdriverio 등을 이용해 브라우저 모드를 지원 합니다. 79 | 80 | ```sh 81 | pnpm add -D @vitest/browser playwright 82 | ``` 83 | 84 | `vitest.config.ts`에 browser 모드를 설정해주고, enabled로 활성화시켜 줍니다. 85 | 86 | ```ts 87 | import { defineConfig } from 'vitest/config'; 88 | import react from '@vitejs/plugin-react'; 89 | /// 90 | 91 | export default defineConfig({ 92 | plugins: [react()], 93 | root: './', 94 | test: { 95 | // ... 96 | browser: { 97 | enabled: true, 98 | name: 'chromium', 99 | headless: true, 100 | provider: 'playwright' 101 | }, 102 | }, 103 | }); 104 | ``` 105 | 106 | 테스트를 실행해도 headless라 브라우저가 보이진 않을 겁니다. 이제 브라우저에는 dom이 있으니, 화면에 렌더하는 테스트를 간단하게 만들어서 확인을 해봅시다. 107 | 108 | ```tsx 109 | import { renderToString } from 'react-dom/server'; 110 | import helloStory from './Hello.fixture'; 111 | import { expect, test } from 'vitest' 112 | 113 | test('render hello', () => { 114 | document.body.innerHTML = renderToString(helloStory); 115 | expect(document.body.innerHTML).toBe('

Hello World!

') 116 | }) 117 | ``` 118 | 119 | playwright는 테스트용 브라우저를 설치하지 않으면 실행되지 않을 때도 있습니다. 경고창이 나왔다면 시키는대로 브라우저를 설치해줍시다. 120 | 121 | ```sh 122 | npx playwright install 123 | ``` 124 | 125 | ### setup testing library 126 | 127 | renderToString 이나 createRoot() 는 번거롭기도 하지만, 테스트가 끝날 때마다 unmount 시켜주고 정리하는 게 번거롭기도 합니다. 그외에도 클릭이나 타이핑 같은 복잡한 사용자 동작을 테스트하기 위해 보통은 @testing-library 를 사용합니다. 128 | 129 | ```sh 130 | pnpm add -D @testing-library/dom @testing-library/jest-dom @testing-library/react @testing-library/user-event 131 | ``` 132 | 133 | setupTests.ts 에 jest-dom을 import하면 `expect(element).toBeInTheDocument()` 같은 유용한 matcher들을 쓸 수 있습니다. 134 | ```ts 135 | // setupTests.ts 136 | import "@testing-library/jest-dom/vitest"; 137 | ``` 138 | 139 | 이제 @testing-library로 컴포넌트를 렌더하고, screen.getByRole 으로 요소(elemen)를 찾아서 가져옵시다. 140 | 141 | ```ts 142 | import { render, screen } from '@testing-library/react'; 143 | import helloStory from './Hello.fixture'; 144 | import { expect, test } from 'vitest' 145 | 146 | test('render hello', () => { 147 | render(helloStory); 148 | 149 | expect(screen.getByRole('heading', { name: "Hello World!" })).toBeInTheDocument() 150 | }) 151 | ``` 152 | 153 | 154 | > biome, pandacss 등의 셋업은 생략합니다. 155 | 156 | ### siheom 157 | 158 | getByRole에는 문제가 하나 있습니다. react를 비롯한 여러 프레임워크들, 그리고 js의 여러 코드들은 렌더나 상태 업데이트 등을 비동기적으로 하는데요. 이따금 테스트 중간에 `await new Promise(resolve => setTimeout(resolve, 100));` 같은 코드를 꼼수로 넣어줘야 하곤 했습니다. 159 | 160 | @testing-library 에는 그래서 `waitFor`이나 `findByXXX` 같은 기능들이 있는데요. 언제 get을 쓰고 언제 find를 써야 하는지 구분하기도 헷갈리지만, waitFor이나 act 등을 사용한 테스트 코드는 장황해질 때도 많습니다. 161 | 162 | playwright의 locator api와 web first assertion은 이러한 문제를 대부분 해결했습니다. 그래서 제가 testing-library를 playwright처럼 생긴 api로 감싼 라이브러리를 만들었는데, 이게 바로 siheom 2.0입니다. 163 | 164 | siheom 2.0은 expectTL과 queryTL 두 개의 파일로 되어 있습니다. 복사해서 프로젝트에 넣어주시면 쉽게 사용하실 수 있습니다. 165 | 166 | > 저는 회사에서 클로저 스크립트로 만든 siheom 3.0 을 쓰고 있는데. 타입스크립트도 지원하도록 패키지화해서 공개할 예정입니다. 167 | 168 | 위에서 작성한 테스트 코드를 다시 작성해보면 다음과 같습니다. 169 | 170 | ```tsx 171 | import { render, screen } from "@testing-library/react"; 172 | import helloStory from "./Hello.fixture"; 173 | import { expect, test } from "vitest"; 174 | import { expectTL } from '@/siheom/expectTL'; 175 | import { queryTL } from '@/siheom/queryTL'; 176 | 177 | test("render hello", async () => { 178 | render(helloStory); 179 | 180 | await expectTL(queryTL.heading("Hello World!")).toBeVisible() 181 | }); 182 | ``` 183 | 184 | 테스트는 보통 assertion이나 user의 행동 둘로 나눠지는데요. 까먹지 말고 둘 다 비동기적으로 await을 붙여줘야 합니다. 비슷한 모양이 반복되니 이번에 눈에 익혀두시면 좋겠네요. 185 | 186 | ```ts 187 | // 단언 188 | await expectTL(queryTL.checkbox("SaaS")).toBeChecked(); 189 | 190 | // 사용자의 행동 191 | await queryTL.button("구매하기").click(); 192 | ``` 193 | 194 | 195 | ### vite vs nextjs webpack의 차이 196 | 197 | nextjs 는 tsconfig에 paths에 적은 alias를 알아서 적용합니다. 198 | 199 | ```json 200 | { 201 | "compilerOptions": { 202 | // ... 203 | "paths": { 204 | "@/*": ["./src/*"], 205 | "@styled-system/*": ["./styled-system/*"] 206 | }, 207 | "types": ["@vitest/browser/providers/playwright"] 208 | }, 209 | // ... 210 | } 211 | ``` 212 | 213 | 하지만 vitest는 명시적으로 플러그인을 사용하거나 `resolve.alias` 를 설정해줘야 합니다. 안 그러면 다음처럼 에러가 납니다. 214 | 215 | ```sh 216 | FAIL src/app/common/components/FilterList/FilterList.test.tsx [ src/app/common/components/FilterList/FilterList.test.tsx ] 217 | FAIL src/app/domains/member/MemberCreateForm/MemberCreateForm.test.tsx [ src/app/domains/member/MemberCreateForm/MemberCreateForm.test.tsx ] 218 | Error: Failed to resolve import "@/app/index.css" from "setupTests.ts". Does the file exist? 219 | ❯ TransformPluginContext._formatError node_modules/.pnpm/vite@5.3.2_@types+node@20.14.9_lightning 220 | ``` 221 | 222 | vite-tsconfig-paths 를 설치하고 설정해줍시다. 223 | 224 | ```sh 225 | pnpm add -D vite-tsconfig-paths 226 | ``` 227 | 228 | ```ts 229 | import tsconfigPaths from "vite-tsconfig-paths"; 230 | import svgr from "vite-plugin-svgr"; 231 | /// 232 | 233 | export default defineConfig({ 234 | plugins: [tsconfigPaths(), react(), svgr({})], 235 | // ... 236 | }) 237 | ``` 238 | 239 | ### 설정 파일을 이해하는 법 240 | 241 | 지금까지 설정하면서 보면 많은 값과 옵션들이 있었는데요. 나중에 문서를 검색해서 천천히 읽어보시는 것도 도움이 되겠지만, 다른 방법도 있습니다. 바로 에러를 내보는 것입니다. 방금 vite-tsconfig-paths가 없을 때 난 에러를 본 것처럼요. 242 | 243 | 예를 들어 `vitest.config.ts`의 옵션을 예로 들어봅시다. 실습을 하시다가 한 번 `pnpm test:headed`로 브라우저 화면이 눈에 보이게 해둔 뒤에, `css: false`로 css 옵션을 꺼보세요. 무슨 일이 벌어질지 예상이 가시나요? 244 | 245 | setupFiles를 지우거나 setupTests.ts 의 경로를 잘못 입력하면 어떤 에러가 나나요? 246 | 247 | `import "@testing-library/jest-dom/vitest";` 대신에 `import "@testing-library/jest-dom"`를 import하면 무슨 일이 벌어지나요? (둘의 차이점이 보이시나요?); 248 | 249 | 250 | 이런 식으로 에러를 내보면 나중에 셋업을 하다가 실수를 하거나 일부를 빼먹어서 에러 메시지를 보더라도, 쉽게 원인을 파악하고 고칠 수 있습니다. 제가 [에러 찾지 말고 일부러 만드세요!](https://twinstae.github.io/experiment-with-error/)라는 글도 썼으니 한 번 시간이 되실 때 읽어보셔도 좋겠습니다. 251 | 252 | ### 목차 253 | 254 | - [하나. 브라우저 컴포넌트 테스트 환경 셋업하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/cosmos) <- 지금 여기 255 | - [둘. 목록과 필터 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/saas/SaasList) 256 | - [셋. 폼 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/member/MemberCreateForm) 257 | - [넷. 헤드리스 컴포넌트로 만든 디자인 시스템 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/common/components/SelectWithCombobox) 258 | -------------------------------------------------------------------------------- /src/app/cosmos/[fixture]/cosmos.decorator.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { ToastContainer } from "react-toastify"; 3 | 4 | export default ({ children }: PropsWithChildren) => ( 5 | <> 6 | {children} 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/app/cosmos/[fixture]/page.tsx: -------------------------------------------------------------------------------- 1 | import { nextCosmosPage, nextCosmosStaticParams } from "react-cosmos-next"; 2 | import * as cosmosImports from "../../../../cosmos.imports"; 3 | 4 | export const generateStaticParams = nextCosmosStaticParams(cosmosImports); 5 | 6 | export default nextCosmosPage(cosmosImports); 7 | -------------------------------------------------------------------------------- /src/app/cosmos/hello.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import helloStory from "./Hello.fixture"; 3 | import { test } from "vitest"; 4 | import { expectTL } from "../../siheom/expectTL"; 5 | import { queryTL } from "../../siheom/queryTL"; 6 | 7 | test("render hello", async () => { 8 | render(helloStory); 9 | 10 | await expectTL(queryTL.heading("Hello World!")).toBeVisible(); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/domains/member/MemberCreateForm/MemberCreateForm.fixture.tsx: -------------------------------------------------------------------------------- 1 | import { MemberCreateFormStory } from "./MemberCreateForm.stories"; 2 | export default { 3 | 기본: , 4 | }; 5 | -------------------------------------------------------------------------------- /src/app/domains/member/MemberCreateForm/MemberCreateForm.stories.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { MemberCreateForm } from "./MemberCreateForm"; 3 | 4 | export const MemberCreateFormStory = () => ( 5 | { 7 | alert(JSON.stringify(newMember)); 8 | }} 9 | /> 10 | ); 11 | -------------------------------------------------------------------------------- /src/app/domains/member/MemberCreateForm/MemberCreateForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { describe, expect, test } from "vitest"; 3 | import { expectTL } from "@/siheom/expectTL"; 4 | import { queryTL } from "@/siheom/queryTL"; 5 | import { MemberCreateForm } from "./MemberCreateForm"; 6 | import { renderWithContext } from "@/siheom/renderWithContext"; 7 | 8 | describe("MemberCreateForm", () => { 9 | test("올바른 정보를 입력하면 멤버를 추가할 수 있다", async () => { 10 | let submitted: null | { name: string; email: string } = null; 11 | renderWithContext( 12 | { 14 | submitted = newMember; 15 | }} 16 | />, 17 | ); 18 | 19 | const form = queryTL.form("멤버 추가"); 20 | 21 | await form.textbox("이름").fill("김태희"); 22 | await form.textbox("회사 이메일").fill("twinstae@naver.com"); 23 | 24 | await form.button("추가하기").click(); 25 | 26 | await expectTL(queryTL.alert()).toHaveText("멤버를 추가했어요!"); 27 | 28 | expect(submitted).toEqual({ 29 | name: "김태희", 30 | email: "twinstae@naver.com", 31 | }); 32 | }); 33 | 34 | test("이름이나 이메일을 입력하지 않으면 에러 메세지를 보여준다", async () => { 35 | let submitted: null | { name: string; email: string } = null; 36 | render( 37 | { 39 | submitted = newMember; 40 | }} 41 | />, 42 | ); 43 | 44 | await queryTL.button("추가하기").click(); 45 | 46 | await expectTL(queryTL.alert("이름을 입력해주세요")).toBeVisible(); 47 | await expectTL(queryTL.alert("이메일을 입력해주세요")).toBeVisible(); 48 | 49 | expect(submitted).toEqual(null); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/app/domains/member/MemberCreateForm/MemberCreateForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { button } from "@/app/common/components/ds"; 3 | import { valibotResolver } from "@hookform/resolvers/valibot"; 4 | import { css, cx } from "@styled-system/css"; 5 | import { vstack } from "@styled-system/patterns"; 6 | import { type ComponentProps, forwardRef, useId } from "react"; 7 | import { FormProvider, useForm, useFormContext } from "react-hook-form"; 8 | import { toast } from "react-toastify"; 9 | import invariant from "tiny-invariant"; 10 | import * as v from "valibot"; 11 | 12 | const NewMemberSchema = v.object({ 13 | name: v.pipe( 14 | v.string(), 15 | v.minLength(1, "이름을 입력해주세요"), 16 | v.maxLength(24, "이름은 24자까지만 입력 가능합니다."), 17 | ), 18 | email: v.pipe( 19 | v.string(), 20 | v.minLength(1, "이메일을 입력해주세요"), 21 | v.email("올바른 이메일 형식을 입력해주세요"), 22 | ), 23 | }); 24 | 25 | const SimpleErrorMessage = ({ name }: { name: string }) => { 26 | const { 27 | formState: { errors }, 28 | } = useFormContext(); 29 | 30 | const errorMessage = errors[name]?.message; 31 | if (errorMessage) { 32 | invariant(typeof errorMessage === "string"); 33 | return ( 34 |

39 | {errorMessage} 40 |

41 | ); 42 | } 43 | return

; 44 | }; 45 | 46 | const Input = forwardRef< 47 | HTMLInputElement, 48 | ComponentProps<"input"> & { name: string } 49 | >((props, ref) => { 50 | const { 51 | formState: { errors }, 52 | } = useFormContext(); 53 | 54 | return ( 55 | 66 | ); 67 | }); 68 | 69 | export const MemberCreateForm = ({ 70 | createMember, 71 | }: { 72 | createMember: (newMember: { name: string; email: string }) => Promise; 73 | }) => { 74 | const methods = useForm<{ name: string; email: string }>({ 75 | resolver: valibotResolver(NewMemberSchema), 76 | }); 77 | 78 | const { register, handleSubmit } = methods; 79 | 80 | const id = useId(); 81 | return ( 82 | 83 |

90 | 멤버 추가 91 |

92 |
{ 105 | createMember(data).then(() => { 106 | toast("멤버를 추가했어요!"); 107 | }); 108 | })} 109 | > 110 | 122 | 134 | 137 |
138 | 139 | ); 140 | }; 141 | -------------------------------------------------------------------------------- /src/app/domains/member/MemberCreateForm/README.md: -------------------------------------------------------------------------------- 1 | ## 폼 컴포넌트 테스트하기 2 | 3 | ### 요구사항 파악하기 4 | 5 | ### input과 label의 접근성 배우기 6 | 7 | ### 폼 작성하고 제출 테스트하기 8 | 9 | ### Input 추출해서 리팩토링하기 10 | 11 | ### 에러 메세지 테스트하기 12 | 13 | ### ErrorMessageForm 추출해서 리팩토링하기 14 | 15 | ### 접근성 요소를 이용해서 스타일링 하기 16 | 17 | ### 토스트 메세지 테스트하기 18 | 19 | ### 목차 20 | 21 | - [하나. 브라우저 컴포넌트 테스트 환경 셋업하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/cosmos) 22 | - [둘. 목록과 필터 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/saas/SaasList) 23 | - [셋. 폼 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/member/MemberCreateForm) <- 지금 여기 24 | - [넷. 헤드리스 컴포넌트로 만든 디자인 시스템 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/common/components/SelectWithCombobox) 25 | -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/README.md: -------------------------------------------------------------------------------- 1 | ## 목록과 필터 컴포넌트 테스트하기 2 | 3 | ### 요구사항과 경우의 수 파악하기 4 | 5 | 처음으로 실습할 예제는 SaaS 목록을 만들어보는 예제입니다. 개발할 때 잘 정리된 케이스나 명세를 받기도 합니다만. 개발자가 기획 단계부터 협업을 하거나, 테스트 케이스나 요구사항을 구체화하고 명세해야 할 때도 많습니다. 다음 스크린샷을 보시죠. 6 | 7 | ![SaaS 목록을 찍은 스크린샷. 먼저 전체 5 결제 내역이 있는 SaaS 3 이라는 radio 버튼 두 개가 있다. 그 다음으로는 Notion 2024년 6월 27일 결제 라고 적힌 링크가 있고. 비슷한 링크가 Notion, Asana, Github, Slack, Zoom 도 있는데. Slack과 Zoom은 결제 날짜가 없다.](./expected.png) 8 | 9 | 한 번 어떤 케이스들과 요구사항이 있을지 생각해봅시다. 실무였다면 모호한 부분은 누군가에게 물어보기도 할 겁니다. 저는 다음과 같이 정리해보았습니다. 10 | 11 | 1. Notion이나 Asana 같이 구독하고 있는 SaaS의 목록을 보여줍니다. 12 | 2. SaaS는 이름과 로고, 그리고 최근 결제한 날짜가 있습니다. 13 | - 이름은 필수이지만, 로고는 없을 수도 있습니다. 로고가 없으면 디폴트 이미지를 보여줍니다. 14 | - 최근 결제한 날짜는 없을 수도 있습니다. 최근에 결제한 SaaS가 위로 올라옵니다. 15 | 3. 각 항목은 해당 SaaS의 상세 페이지로 가는 링크입니다. 16 | 4. 필터가 있습니다. 전체와 결제 내역이 있는 SaaS, 두 가지가 있고요. 필터된 SaaS의 개수를 옆에 보여줍니다. 선택한 필터에 해당하는 SaaS만 보여줍니다. 17 | 18 | 테스트 케이스는 다양한 경우의 수를 커버해야 합니다. 그러면 여기서 어떤 경우의 수가 중요할까요? 19 | 20 | 1. 결제 내역과 로고가 둘 다 있는 경우 21 | 2. 결제 내역이 없는 경우 22 | 3. 로고가 없는 경우 23 | 4. 둘 다 없고 이름만 있는 경우 24 | 25 | 등이 떠오릅니다. 그러면 이 4가지 경우마다 테스트 데이터를 만들어줘야겠네요. 26 | 27 | ### 스토리북과 픽스처 준비하기 28 | 29 | 그러면 이제 본격적으로 제대로 된 컴포넌트를 만들고 테스트를 해봅시다. 30 | 31 | 혹시 이미 켜두시지 않았다면, 터미널 탭을 3개 열고 next server, 스토리북, 테스트를 하나씩 켜줍시다. 32 | 33 | ```sh 34 | pnpm dev 35 | # 다른 터미널을 새로 열고 36 | pnpm story 37 | # 또 다른 터미널을 새로 열고 38 | pnpm test 39 | ``` 40 | 41 | 먼저 가장 귀찮은 일부터 합시다. 테스트 데이터를 만들고 스토리북을 만드는 것입니다. 42 | 43 | 이런 테스트 데이터는 AI에게 기본 틀을 짜달라고 하면 편합니다만. 그래도 인간의 손을 거치지 않으면 뒤통수를 맞는 경우가 많습니다. (제가 예제를 만들다가 뒤통수를 맞아서 이렇게 쓴 게 맞습니다) 저는 다음과 같이 만들어 보았습니다. 44 | 45 | ```tsx 46 | // domains/saas/SaasList/SaasListItem.fixture.tsx 47 | 48 | import { SaasListItem } from "./SaasListItem"; 49 | 50 | export const 로고_결제내역_있는_SaaS = { 51 | id: "ef4cdc19-a393-478f-8caf-9d1ab7f7a82e", 52 | name: "Notion", 53 | logoUrl: "https://assets-dev.smply.app/saas-logos/9-IepQ3Rrt.png", 54 | lastPaidAt: new Date("2024-06-27"), 55 | }; 56 | 57 | export const 결재내역_없는_SaaS = { 58 | id: "c54e160e-feb2-4545-b0a4-a2ed7f70e6d2", 59 | name: "Slack", 60 | logoUrl: 61 | "https://assets-global.website-files.com/621c8d7ad9e04933c4e51ffb/65eba5ffa14998827c92cc01_slack-octothorpe.png", 62 | }; 63 | 64 | export const 로고_없는_SaaS = { 65 | id: "a983512a-4daf-45b0-892d-d7e8399d982b", 66 | name: "GitHub", 67 | lastPaidAt: new Date("2024-06-15"), 68 | }; 69 | 70 | export const 이름만_있는_SaaS = { 71 | id: "469413ca-1c22-480a-8adc-7f6b00d17324", 72 | name: "Zoom", 73 | }; 74 | 75 | export default { 76 | "로고 결제내역 있는 SaaS": , 77 | "결재내역 없는 SaaS": , 78 | "로고 없는 SaaS": , 79 | "이름만 있는 SaaS": , 80 | }; 81 | ``` 82 | 83 | 그러면 SaasListItem이 없으니 타입스크립트 컴파일러가 화를 낼 겁니다. 84 | 85 | > File '/home/taehee/github/turing-frontend-test/src/app/domains/saas/SaasList/SaasListItem.tsx' is not a module.ts(2306) 86 | 87 | `is not a module` 은 해당 파일에서 export한 게 없다는 뜻입니다. SaasListItem 컴포넌트 함수를 만들고 export를 해줍시다. 88 | 89 | ```tsx 90 | // domains/saas/SaasList/SaasListItem.tsx 91 | export const SaasListItem = ({ 92 | saas, 93 | }: { 94 | saas: { 95 | id: string; 96 | name: string; 97 | logoUrl?: string; 98 | lastPaidAt?: Date; 99 | }; 100 | }) => { 101 | }; 102 | ``` 103 | 104 | 아니 그런데 여전히 컴파일러가 화를 냅니다. 105 | 106 | > 'SaasListItem' cannot be used as a JSX component. 107 | Its type '({ saas, }: { saas: { id: string; name: string; logoUrl?: string; lastPaidAt?: Date; };}) => void' is not a valid JSX element type. 108 | 109 | void는 함수가 반환하는 값이 없다는 뜻입니다. 보통 jsx를 리턴하는 걸 까먹으면 나는 에러입니다. 일단 li를 하나 반환하게 합시다. 화면에도 `test!`가 보여야겠죠? 110 | 111 | ```tsx 112 | // domains/saas/SaasList/SaasListItem.tsx 113 | export const SaasListItem = ({ 114 | saas, 115 | }: // ... 116 | ) => { 117 |
  • test!
  • 118 | }; 119 | ``` 120 | 121 | 아니 여전히 같은 에러가 납니다. 화면에도 아무 것도 안 보이고요. 왜일까요? `return`을 빼먹었기 때문입니다. 이렇게 간단한 거에서도 실수하는데. 복잡한 코드를 짜면 왜 흰 화면만 나오는 거야 30분 동안 고민하다가 동료에게 질문하는 일이 생겨서, 실제 경험담으로 강의에서 소개하게 될지도 모르겠군요. 122 | 123 | ```tsx 124 | // domains/saas/SaasList/SaasListItem.tsx 125 | export const SaasListItem = ({ 126 | saas, 127 | }: // ... 128 | ) => { 129 | return
  • test!
  • ; // <- return을 까먹지 말자 130 | }; 131 | ``` 132 | 133 | 와와. Test는 한 줄도 짜지 않았는데, 타입 에러를 고치다보니 TDD를 한 것 같죠? 이런 걸 Type Driven Development라고 하기도 합니다. 134 | 135 | 하지만 여기에서 타입의 한계도 마주치게 됩니다. 실제로 구현된 건 하나도 없는데 타입 에러가 더 이상 나지 않는군요. 물론 경고가 뜨긴 합니다. 136 | 137 | > 'saas' is declared but its value is never read 138 | 139 | 이제 테스트를 작성하고 하나씩 구현을 할 시간입니다. 140 | 141 | ### 테스트 먼저 작성하고 구현하기 142 | 143 | 먼저 더 단순한 이름만 있는 SaaS부터 시작해봅시다. 우리는 요구사항을 웹 표준과 접근성의 언어로 변환해야 합니다. 이름만 있는 SaaS에 우리는 뭘 기대할까요? 144 | 145 | - 이름이 보인다. 146 | - 결제 일자는 보이지 않는다. 147 | 148 | 아, 물론 그렇게 생각할 수도 있습니다. 하지만 이름은 그저 텍스트일까요? 아까 보여드린 이미지 -피그마처럼 생각하셔도 됩니다-에서 최근 결제일은 좀 더 연한 글씨로 덜 강조되는데, SaaS의 이름은 진한 글씨로 강조하고 있습니다. 149 | 150 | 이렇듯 어떤 listitem의 제목은 보통 h3 같은 `heading`으로 만듭니다. 그러니 다음처럼 작성할 수 있습니다. 151 | 152 | ```ts 153 | // domains/saas/SaasList/SaasListItem.test.tsx 154 | import { render } from "@testing-library/react"; 155 | import SaasListItemStories from "./SaasListItem.fixture"; 156 | import { describe, test } from "vitest"; 157 | import { expectTL } from "@/siheom/expectTL"; 158 | import { queryTL } from "@/siheom/queryTL"; 159 | 160 | describe("SaasListItem", () => { 161 | test("이름만 있는 SaaS", async () => { 162 | render(SaasListItemStories["이름만 있는 SaaS"]); 163 | 164 | await expectTL(queryTL.heading("Zoom")).toBeVisible(); 165 | await expectTL(queryTL.text("결제")).not.toBeVisible(); 166 | }); 167 | }); 168 | ``` 169 | 170 | 이러면 당연하게도 테스트는 실패합니다. 171 | 172 | ```sh 173 | FAIL src/app/domains/saas/SaasList/SaasListItem.test.tsx > SaasListItem > 이름만 있는 SaaS 174 | TestingLibraryElementError: Unable to find role="heading" and name `/Zoom/i` 175 | Ignored nodes: comments, script, style 176 | 177 |
    178 |
  • 179 | test! 180 |
  • 181 |
    182 | 183 | ``` 184 | 에러를 읽어봅시다. 이름에 Zoom 을 포함하고 있는 heading을 찾지 못했다네요. 화면에는 임시로 넣어놓은 test! 만 보입니다. 185 | 186 | 이걸 고치는 건 쉽죠. h3를 추가합시다. 187 | 188 | ```tsx 189 | // domains/saas/SaasList/SaasListItem.tsx 190 | export const SaasListItem = ({ 191 | saas, 192 | }: // ... 193 | ) => { 194 | return
  • {saas.name}

  • ; // <- return을 까먹지 말자 195 | }; 196 | ``` 197 | 198 | 와와. PASS 를 보셨나요? 아직 경우의 수가 많습니다. 결제내역이 있는 경우는 어떨까요? 날짜는 그저 `text`일 뿐 입니다만. 날짜를 기대하는대로 포매팅해주는 게 좀 귀찮습니다. 199 | 200 | ```ts 201 | // domains/saas/SaasList/SaasListItem.test.tsx 202 | // ... 203 | 204 | describe("SaasListItem", () => { 205 | test("이름만 있는 SaaS", async () => { 206 | render(SaasListItemStories["이름만 있는 SaaS"]); 207 | 208 | await expectTL(queryTL.heading("Zoom")).toBeVisible(); 209 | await expectTL(queryTL.text("결제")).not.toBeVisible(); 210 | }); 211 | 212 | // 새 테스트 케이스를 추가! 213 | test("로고 결제내역 있는 SaaS", async () => { 214 | render(SaasListItemStories["로고 결제내역 있는 SaaS"]); 215 | 216 | await expectTL(queryTL.heading("Notion")).toBeVisible(); 217 | await expectTL(queryTL.text("2024년 6월 27일 결제")).toBeVisible(); 218 | }); 219 | }); 220 | ``` 221 | 222 | 또 테스트가 실패합니다. 223 | 224 | ```sh 225 | FAIL src/app/domains/saas/SaasList/SaasListItem.test.tsx > SaasListItem > 로고 결제내역 있는 SaaS 226 | TestingLibraryElementError: Unable to find an element with the text: 2024년 6월 27일 결제. 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. 227 | ``` 228 | 229 | 음 이번에는 text가 없다네요. 그러면 구현을 합시다. `date-fns` 같은 라이브러리를 쓸 수도 있지만, 일단 직접 해봅시다. 230 | 231 | ```tsx 232 | function formatDateYearMonthDate(date: Date): string { 233 | return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; 234 | } 235 | 236 | export const SaasListItem = ({ 237 | saas, 238 | }: // ... 239 | ) => { 240 | return ( 241 |
  • 242 |

    {saas.name}

    243 |

    {formatDateYearMonthDate(saas.lastPaidAt)} 결제

    244 |
  • 245 | ); 246 | }; 247 | ``` 248 | 249 | 와와 통과!... 일줄 알았는데 깨집니다. 250 | 251 | ```sh 252 | × 이름만 있는 SaaS 253 | ✓ 로고 결제내역 있는 SaaS 254 | 255 | FAIL src/app/domains/saas/SaasList/SaasListItem.test.tsx > SaasListItem > 이름만 있는 SaaS 256 | TypeError: Cannot read properties of null (reading 'getFullYear') 257 | ❯ formatDateYearMonthDate src/app/domains/saas/SaasList/SaasListItem.tsx:2:17 258 | 1| function formatDateYearMonthDate(date: Date): string { 259 | 2| return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; 260 | | ^ 261 | 3| } 262 | ``` 263 | 264 | 이런 결제내역이 있는 경우는 성공했는데, 없는 경우가 터졌군요. 타입스크립트도 같이 빨간 줄이 뜨고 있습니다. 265 | 266 | > Argument of type 'Date | null' is not assignable to parameter of type 'Date'.
    267 | > Type 'null' is not assignable to type 'Date'.ts(2345) 268 | 269 | 어떻게 하면 될까요? 조건부로 렌더하면 null 에러가 사라질 겁니다. 후후. 270 | 271 | 272 | ```tsx 273 | function formatDateYearMonthDate(date: Date): string { 274 | return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; 275 | } 276 | 277 | export const SaasListItem = ({ 278 | saas, 279 | }: // ... 280 | ) => { 281 | return ( 282 |
  • 283 |

    {saas.name}

    284 | {saas.lastPaidAt &&

    {formatDateYearMonthDate(saas.lastPaidAt)} 결제

    } 285 |
  • 286 | ); 287 | }; 288 | ``` 289 | 290 | 와와 그러면 모든 테스트가 통과합니다. 누군가 null 에러를 없앤다고 !를 쓴 게 은밀하게 코드리뷰를 통과해서 배포된 앱이 오픈 첫날에 터지는 일은 없겠군요. 291 | 292 | ```sh 293 | ✓ src/app/domains/saas/SaasList/SaasListItem.test.tsx (2) 294 | ✓ SaasListItem (2) 295 | ✓ 이름만 있는 SaaS 296 | ✓ 로고 결제내역 있는 SaaS 297 | 298 | Test Files 1 passed (1) 299 | Tests 2 passed (2) 300 | Start at 19:53:13 301 | Duration 6.76s (transform 0ms, setup 595ms, collect 57ms, tests 73ms, environment 0ms, prepare 530ms) 302 | 303 | PASS Waiting for file changes... 304 | ``` 305 | 306 | ### 링크 테스트의 아쉬움 307 | 308 | 아직 SaaS 항목들은 눌러도 아무 일도 일어나지 않는 li 일 뿐입니다. 그러면 링크인지는 어떻게 테스트할까요? 실제로 눌러보고 해당 페이지로 이동하는지 보는 것도 좋습니다만. component 테스트에서는 보통 선언적으로, 해당 페이지로 가능 링크만 가지고 있는지 보는 게 보통입니다. 309 | 310 | ```ts 311 | test("항목은 해당 SaaS의 상세 페이지로 가능 링크다", async () => { 312 | render(SaasListItemStories["로고 결제내역 있는 SaaS"]); 313 | 314 | await expectTL(queryTL.link("Notion")).toHaveAttribute('href', `/saas/${로고_결제내역_있는_SaaS.id}`); 315 | }); 316 | ``` 317 | 318 | 이를 구현하는 것도 간단합니다. 저는 귀찮더라도 NextLink를 감싸서 Link 컴포넌트를 만들어서 사용했습니다. 319 | 320 | ```tsx 321 | // src/app/routing/Link.tsx 322 | import NextLink from "next/link"; 323 | import type { PropsWithChildren } from "react"; 324 | 325 | export const Link = ({ 326 | className, 327 | href, 328 | children, 329 | }: PropsWithChildren<{ className?: string; href: string }>) => { 330 | return ( 331 | 332 | {children} 333 | 334 | ); 335 | }; 336 | ``` 337 | 338 | ```tsx 339 | import { Link } from "@/app/routing/Link"; 340 | // ... 341 | 342 | // ... 343 |
  • 344 | 345 |

    {saas.name}

    346 | {saas.lastPaidAt &&

    {formatDateYearMonthDate(saas.lastPaidAt)} 결제

    } 347 | 348 |
  • 349 | ``` 350 | 351 | 하지만 여기에는 실제로 페이지를 이동해보는 것에 비하면, 좀 아쉬운 부분이 있습니다. 일단 실제로 `/saas/[saas-id]`라는 페이지가 정의되었는지 보장해주지 않습니다. 업무를 하다보면 실수로 페이지의 경로에 오타를 내거나, 페이지 경로를 `/saas/`에서 `/saas-detail/`로 바꾸는 식의 작업을 할 때도 있을텐데 말이죠. 실제로 저희는 아직 saas 상세 페이지를 정의하지 않았는데 타입 에러도, 테스트도 깨지지 않고 있습니다. 352 | 353 | 그래서 모든 경로를 Path.ts 같은 폴더를 만들어서 정의해서 쓰는 분도 있고요. clojure 생태계에서 사용하는 reitit router에서는 route들을 데이터로 정의하고, link의 route가 정의되지 않으면 href를 만들어주지 않아서 에러를 던졌습니다. 웹 표준에 따르면 href가 없으면 a 태그여도 link가 아니기 때문이죠. 354 | 355 | 최근에는 tanstack router가 typed route를 개척했고요. remix나 nextjs도 실험적 기능이나 외부 플러그인 등으로 type 안전한 routing을 지원하고 있으니 참고해보시길 바랍니다. 356 | 357 | ### 스토리북과 스타일 358 | 359 | 타입도 테스트도 만족 시켰지만. 스토리북을 보면 빠진 게 있다는 걸 쉽게 알 수 있습니다. 360 | 361 | ![스타일도 이미지도 없이 SaaS의 이름과 결제일만 덩그러니 있는 모습](unstyled-no-image.png) 362 | 363 | 이미지는 그러면 어떻게 테스트하면 좋을까요? 물론 alt text에 이상한 걸 넣고 나는 테스트를 짜는 훌륭한 개발자야 하고 자화자찬할 수도 있습니다만. 이미지가 text로 대체되는 게 아니라면 `role="presentation"`을 달고 `alt=""`로 비워주는 게 맞습니다. 364 | 365 | > alt="saas 로고 이미지" 라거나 alt="saas 기본 이미지" 같이 무의미한 테스트용 정보를 alt에 넣지 마십시오. 접근성은 장난도 아니고 테스트용 기능도 아닙니다. (엄격 근엄 진지) 366 | 367 | 그러면 테스트 대신에 어떻게 하면 좋을까요? 그런 이유로 스토리북이 있습니다. 스토리북에서 눈으로 보면서 확인하세요. 물론 세상에는 스냅샷 테스트 혹은 visual test 라고 부르는 도구들도 있습니다만. 그 친구들도 그 나름의 심연이 있고 나중에 설명해보려 합니다. 368 | 369 | 이때도 이왕이면 최대한 실제 의존성을 쓰시면 좋습니다. 테스트 환경에서는 local image를 쓰더라도 실제 cdn이나 정적 이미지 서빙하는 서버가 죽거나 해서 이미지가 안 보이면 큰일 이니까요. (실제로 그런 이유로 이미지 서버가 죽었는데 사용자 문의가 올 때까지 방치된 채 페이지를 여럿 봤습니다) 370 | 371 | 여튼 이미지를 디폴트 값과 함께 렌더해주고, 스타일을 넣어주면 예쁜 스토리북을 볼 수 있습니다. 372 | 373 | ### 목록의 역할 각 항목의 역할 374 | 375 | 저희는 지금 목록을 만드는데 SaasList보다 SaasListItem을 먼저 작업하고 있습니다. 이건 꽤 흔한 패턴이기도 합니다. 376 | 377 | 보통 각 항목들은 경우의 수가 다양합니다. 저는 그래서 보통 다양한 경우의 수마다 항목별로 스토리북을 만들어서 테스트를 합니다. 378 | 379 | 목록은 보통 정렬을 하거나, 필터를 하거나, 무한 스크롤을 하기 때문에. 목록 컴포넌트에서는 이러한 상위 수준의 기능을 테스트하고는 합니다. 380 | 381 | 그러면 이제 목록 테스트로 들어가봅시다. 382 | 383 | ### 정렬을 테스트하기 384 | 385 | 일단 아까 요구사항을 되새겨봅시다. 최근에 결제된 SaaS가 앞에 와야 했습니다. 이를 테스트로 작성하려니... 귀찮습니다. 어떻게 하면 좋을까요? 386 | 387 | 이때 쓰기 좋은 꼼수가 승인 테스트(approval test)입니다. 388 | 389 | 일단 간단하게 구현을 합니다. 390 | 391 | ```tsx 392 | "use client"; 393 | import { SaasListItem } from "./SaasListItem"; 394 | 395 | export const SaasList = ({ 396 | saasList, 397 | }: { 398 | saasList: { 399 | id: string; 400 | name: string; 401 | logoUrl: string | null; 402 | lastPaidAt: Date | null; 403 | }[]; 404 | }) => { 405 | 406 | return ( 407 |
      408 | {saasList 409 | .map((saas) => ( 410 | 411 | ))} 412 |
    413 | ); 414 | }; 415 | ``` 416 | 417 | 일단 빈 값을 넣어서 테스트를 실패하게 만듭니다. `toHaveTextContents` 는 query한 요소들이 기대한 텍스트를 가지고 렌더되는지 봅니다. 주로 리스트를 테스트할 때 쓰려고 만들었습니다. 418 | 419 | ```tsx 420 | import { render } from "@testing-library/react"; 421 | import SaasListStories from "./SaasList.fixture"; 422 | import { describe, test } from "vitest"; 423 | import { expectTL } from "@/siheom/expectTL"; 424 | import { queryTL } from "@/siheom/queryTL"; 425 | 426 | describe("SaasList", () => { 427 | test("결제 내역 있는 SaaS만 필터할 수 있다", async () => { 428 | render(SaasListStories["Saas가 여럿 있음"]); 429 | 430 | // given 최근 결제일 순으로 정렬됨 431 | await expectTL(queryTL.listitem("")).toHaveTextContents([ 432 | // 일부러 빈 배열을 넣습니다. 433 | ]); 434 | }); 435 | }); 436 | ``` 437 | 438 | 그러면 당연히 테스트가 실패합니다. 빈 값이 아니니까요. 439 | 440 | ```sh 441 | 442 | FAIL src/app/domains/saas/SaasList/SaasList.test.tsx > SaasList > 결제 내역 있는 SaaS만 필터할 수 있다 443 | AssertionError: expected [ 'Notion2024년 6월 27일 결제', …(4) ] to deeply equal [] 444 | 445 | Ignored nodes: comments, script, style 446 | 447 | ... 이하 생략 448 | 449 | - Expected 450 | + Received 451 | 452 | - Array [] 453 | + Array [ 454 | + "Notion2024년 6월 27일 결제", 455 | + "Slack", 456 | + "GitHub2024년 6월 15일 결제", 457 | + "Zoom", 458 | + "Asana2024년 6월 20일 결제", 459 | + ] 460 | ``` 461 | 462 | 그러면 저 배열의 실제 값(Received)을 그대로 복사해서 저희 테스트에 넣습니다. 그리고 눈으로 보고 직접 정렬하면 됩니다. 27일 20일 15일 slack, zoom 순서로 말이죠. 463 | 464 | ```ts 465 | import { render } from "@testing-library/react"; 466 | import SaasListStories from "./SaasList.fixture"; 467 | import { describe, test } from "vitest"; 468 | import { expectTL } from "@/siheom/expectTL"; 469 | import { queryTL } from "@/siheom/queryTL"; 470 | 471 | describe("SaasList", () => { 472 | test("결제 내역 있는 SaaS만 필터할 수 있다", async () => { 473 | render(SaasListStories["Saas가 여럿 있음"]); 474 | 475 | // given 전체 SaaS 필터 체크됨 476 | await expectTL(queryTL.radio("전체 5")).toBeChecked(); 477 | await expectTL(queryTL.list("SaaS 목록")).toBeVisible(); 478 | 479 | // given 최근 결제일 순으로 정렬됨 480 | await expectTL(queryTL.listitem("")).toHaveTextContents([ 481 | "Notion2024년 6월 27일 결제", 482 | "Asana2024년 6월 20일 결제", 483 | "GitHub2024년 6월 15일 결제", 484 | "Slack", 485 | "Zoom", 486 | ]); 487 | }) 488 | }) 489 | ``` 490 | 491 | 그러면 당연히 테스트가 실패하겠죠. 저희 구현에서는 아직 정렬을 안 했으니까요. 492 | 493 | ```sh 494 | - Expected 495 | + Received 496 | 497 | Array [ 498 | "Notion2024년 6월 27일 결제", 499 | - "Asana2024년 6월 20일 결제", 500 | - "GitHub2024년 6월 15일 결제", 501 | "Slack", 502 | + "GitHub2024년 6월 15일 결제", 503 | "Zoom", 504 | + "Asana2024년 6월 20일 결제", 505 | ] 506 | ``` 507 | 508 | 예상대로입니다. 정렬을 시킵시다. 509 | 510 | ```tsx 511 |
      512 | {saasList 513 | .sort( 514 | (a, b) => 515 | (a.lastPaidAt?.valueOf() ?? 0) - (b.lastPaidAt?.valueOf() ?? 0), 516 | ) 517 | .map((saas) => ( 518 | 519 | ))} 520 |
    521 | ``` 522 | 523 | 아니 그런데 테스트가 실패합니다. 524 | 525 | ```sh 526 | - Expected 527 | + Received 528 | 529 | Array [ 530 | - "Notion2024년 6월 27일 결제", 531 | - "Asana2024년 6월 20일 결제", 532 | - "GitHub2024년 6월 15일 결제", 533 | "Slack", 534 | "Zoom", 535 | + "GitHub2024년 6월 15일 결제", 536 | + "Asana2024년 6월 20일 결제", 537 | + "Notion2024년 6월 27일 결제", 538 | ] 539 | ``` 540 | 541 | 이런 정렬이 거꾸로 된 것 같네요? a와 b의 순서를 뒤집어봅시다. 542 | 543 | ```tsx 544 |
      545 | {saasList 546 | .sort( 547 | (a, b) => 548 | (b.lastPaidAt?.valueOf() ?? 0) - (a.lastPaidAt?.valueOf() ?? 0), 549 | ) 550 | .map((saas) => ( 551 | 552 | ))} 553 |
    554 | ``` 555 | 556 | 와와 이번에는 성공입니다. 테스트 데이터에 값이 없는 경우도 포함되었으니 null 처리를 잘했는지도 같이 테스트가 되었네요. (한 번 null 처리 된 부분을 지워보시면 타입 에러도 나겠지만, test도 깨지는 걸 보실 수 있을 겁니다) 557 | 558 | ```sh 559 | ✓ src/app/domains/saas/SaasList/SaasListItem.test.tsx (3) 560 | ✓ src/app/domains/saas/SaasList/SaasList.test.tsx (1) 561 | 562 | Test Files 2 passed (2) 563 | Tests 4 passed (4) 564 | Start at 20:59:56 565 | Duration 836ms 566 | 567 | 568 | PASS Waiting for file changes... 569 | press h to show help, press q to quit 570 | ``` 571 | 572 | ### 필터를 테스트하기 573 | 574 | 이제는 필터를 구현해봅시다. 575 | 576 | 별도의 테스트 케이스를 작성할 수도 있지만, 저는 사용자가 아무 동작도 하지 않은 경우는 given으로 두고, 사용자의 동작을 시나리오로 작성하는 걸 더 선호합니다. 577 | 578 | when 은 보통 사용자 동작이고 then은 보통 그렇게 변화된 UI의 상태입니다. 그러면 우리는 필터 기능에 어떤 시나리오를 기대할까요? 먼저 인간의 언어로 적어보자면 다음과 같습니다. 579 | 580 | - given 원래 전체 5가 체크되어 있고, 5개가 모두 보였는데 581 | - when 결제 내역 있는 SaaS 3 를 클릭하니 582 | - then 결제 내역 있는 SaaS 3개만 보인다 583 | 584 | 여기서 잠시 궁금한 점은 `전체 5`나 `결제 내역 있는 SaaS 3`의 role이 무엇이냐는 건데요. 사람들은 div에 onClick을 달아서 쓰기도 합니다만. 우리는 생긴 것과 상관 없이 동작을 보고 역할을 생각해야 합니다. 585 | 586 | - 페이지를 이동하거나 한다면 보통 `link` role 이고 `` 태그를 사용합니다. 587 | - 한 가지만 선택 가능한데 모든 선택지가 보인다면 `radio` role이고 ``를 씁니다. 588 | - 동시에 여러가지를 체크할 수 있고 모든 선택지가 보인다면 보통 `checkbox` role이고 ``를 사용합니다. 589 | - 값을 선택할 수 있는데 누르면 `option`들의 팝오버가 뜬다면 보통 `combobox` role이고 ` { 661 | setSelected("all"); 662 | }} 663 | /> 664 | 665 | 666 | ... 667 | 668 | ); 669 | } 670 | ``` 671 | 672 | ### label과 input의 관계 673 | 674 | 여기서 주의할 점은 label 안에 input을 넣어야 label 안에 있는 text가 input의 접근 가능한 이름이 된다는 겁니다. 한 번 input을 label 밖으로 빼보시면 input을 찾지 못하고 테스트가 깨지는 걸 볼 수 있습니다. 675 | 676 | input이 label 바깥에 있을 때에도 htmlFor과 id를 이용해서 연결해줄 수 있습니다. input에 id를 달고 label에 htmlFor로 id를 넣어주면 됩니다. 677 | 678 | ```tsx 679 | 682 | { 688 | setSelected("all"); 689 | }} 690 | /> 691 | ``` 692 | 693 | 이렇게 매번 id를 생각해내는 건 번거롭기도 하지만, 공용 컴포넌트에서도 문제가 되고요. id가 충돌할 가능성도 있기 때문에, react 18부터는 useId 훅으로 id를 생성할 수 있습니다. 694 | 695 | ```tsx 696 | const id = useId() 697 | 698 | 701 | { 707 | setSelected("all"); 708 | }} 709 | /> 710 | ``` 711 | 712 | ### 다시 필터 테스트하기 713 | 714 | 이제 앞서 주석처리했던 when 절을 다시 살려냅시다. 715 | 716 | 717 | ```ts 718 | test("결제 내역 있는 SaaS만 필터할 수 있다", async () => { 719 | render(SaasListStories["Saas가 여럿 있음"]); 720 | 721 | // given 전체 SaaS 필터 체크됨 722 | await expectTL(queryTL.radio("전체 5")).toBeChecked(); 723 | await expectTL(queryTL.list("SaaS 목록")).toBeVisible(); 724 | 725 | // given 최근 결제일 순으로 정렬됨 726 | await expectTL(queryTL.listitem("")).toHaveTextContents([ 727 | "Notion2024년 6월 27일 결제", 728 | "Asana2024년 6월 20일 결제", 729 | "GitHub2024년 6월 15일 결제", 730 | "Slack", 731 | "Zoom", 732 | ]); 733 | 734 | // when 결제 내역 있는 SaaS 라디오를 클릭하면 735 | await queryTL.radio("결제 내역 있는 SaaS 3").click(); 736 | 737 | // // then 결제 내역 있는 SaaS만 필터됨 738 | // await expectTL(queryTL.listitem("")).toHaveTextContents([ 739 | // "Notion2024년 6월 27일 결제", 740 | // "Asana2024년 6월 20일 결제", 741 | // "GitHub2024년 6월 15일 결제", 742 | // ]); 743 | }); 744 | ``` 745 | 746 | 그러면 또 테스트가 실패합니다. 747 | 748 | ```sh 749 | FAIL src/app/domains/saas/SaasList/SaasList.test.tsx > SaasList > 결제 내역 있는 SaaS만 필터할 수 있다 750 | TestingLibraryElementError: Unable to find role="radio" and name `/결제 내역 있는 SaaS 3/i` 751 | ``` 752 | 753 | 최근 결제일이 있는 SaaS만 필터해서 radio를 하나 만듭시다. 754 | 755 | ```tsx 756 | const saasWithPaymentList = saasList.filter( 757 | (saas) => saas.lastPaidAt instanceof Date, 758 | ); 759 | 760 | // ... 761 |
    762 | 필터 763 | 774 | 785 |
    786 | ``` 787 | 788 | 와와 그러면 또 테스트가 통과합니다. 이제 마지막 then 절을 살립시다. 789 | 790 | 791 | ```ts 792 | test("결제 내역 있는 SaaS만 필터할 수 있다", async () => { 793 | render(SaasListStories["Saas가 여럿 있음"]); 794 | 795 | // given 전체 SaaS 필터 체크됨 796 | await expectTL(queryTL.radio("전체 5")).toBeChecked(); 797 | await expectTL(queryTL.list("SaaS 목록")).toBeVisible(); 798 | 799 | // given 최근 결제일 순으로 정렬됨 800 | await expectTL(queryTL.listitem("")).toHaveTextContents([ 801 | "Notion2024년 6월 27일 결제", 802 | "Asana2024년 6월 20일 결제", 803 | "GitHub2024년 6월 15일 결제", 804 | "Slack", 805 | "Zoom", 806 | ]); 807 | 808 | // when 결제 내역 있는 SaaS 라디오를 클릭하면 809 | await queryTL.radio("결제 내역 있는 SaaS 3").click(); 810 | 811 | // then 결제 내역 있는 SaaS만 필터됨 812 | await expectTL(queryTL.listitem("")).toHaveTextContents([ 813 | "Notion2024년 6월 27일 결제", 814 | "Asana2024년 6월 20일 결제", 815 | "GitHub2024년 6월 15일 결제", 816 | ]); 817 | }); 818 | ``` 819 | 820 | 그러면 테스트가 또 실패합니다. 필터는 했지만, 항상 전체 목록을 보여주고 있거든요. 821 | 822 | ```sh 823 | - Expected 824 | + Received 825 | 826 | Array [ 827 | "Notion2024년 6월 27일 결제", 828 | "Asana2024년 6월 20일 결제", 829 | "GitHub2024년 6월 15일 결제", 830 | + "Slack", 831 | + "Zoom", 832 | ] 833 | ``` 834 | 835 | 선택된 필터가 전체일 때에만 전체 목록을 보여주도록 삼항 연산자로 구현을 해봤습니다. 836 | 837 | ```tsx 838 |
      839 | {(selected === "all" ? saasList : saasWithPaymentList) 840 | .sort( 841 | (a, b) => 842 | (b.lastPaidAt?.valueOf() ?? 0) - (a.lastPaidAt?.valueOf() ?? 0), 843 | ) 844 | .map((saas) => ( 845 | 846 | ))} 847 |
    848 | ``` 849 | 850 | 그러면 모두 통과합니다! 이제 또 스타일링만 하면 되겠군요? 851 | 852 | ```sh 853 | ✓ src/app/domains/saas/SaasList/SaasListItem.test.tsx (3) 854 | ✓ src/app/domains/saas/SaasList/SaasList.test.tsx (1) 855 | 856 | Test Files 2 passed (2) 857 | Tests 4 passed (4) 858 | Start at 21:26:34 859 | Duration 1.23s 860 | 861 | 862 | PASS Waiting for file changes... 863 | press h to show help, press q to quit 864 | ``` 865 | 866 | ### 웹표준을 이용한 스타일링 867 | 868 | 이번에 링크와 라디오가 있었는데요. 저희는 테스트를 작성했지만, 스타일이 경우의 수마다 잘 들어갔는지는 어떻게 확인할까요? 예를들어 선택된 필터가 파란색이 되는지를 테스트해야 할까요? 물론 스토리북을 보는 것도 방법입니다만. 상태에 따라 className을 다르게 넣어주는 식으로 js 로직을 작성한다면 실수할 여지가 커지는 것도 사실입니다. 869 | 870 | 제가 추천드리는 방법은 접근성 aria attribute와 웹 표준을 활용하는 겁니다. 871 | 872 | 예를 들어 link에 hover하거나 키보드로 focus했을 때 스타일은 다음과 같이 쉽게 적용할 수 있습니다. 873 | 874 | ```css 875 | a.saas-list-item { 876 | background: var(--gray-white); 877 | } 878 | 879 | a.saas-list-item:is(:hover, :focus-visible) { 880 | background: var(--gray-50); 881 | } 882 | ``` 883 | 884 | check된 radio의 스타일은 어떻게 할까요? 역시 비슷하게 할 수 있습니다. 885 | 886 | ```css 887 | label.filter-button { 888 | border-radius: 9999px; 889 | padding: 8px 12px; 890 | border-width: 1px; 891 | border-color: transparent; 892 | background-color: #f1f4f6; 893 | transition: all 0.125s ease-in-out; 894 | 895 | /* label 안의 input이 체크되어 있으면 파란색이 되게 합니다 */ 896 | &:has(input:checked) { 897 | border-color: #4f89fb; 898 | background-color: #eaf3fe; 899 | color: #1863f6; 900 | } 901 | } 902 | ``` 903 | 904 | 이렇게 스타일을 작성하면, 저희는 실제로 input이 기대하는대로 체크되었는지만 테스트하면 됩니다. radio는 같은 name= attribute를 가진 radio 중에 하나만 선택되는 걸 보장해주기 때문에 해당 동작을 매번 테스트하지 않아도 되고요. radiogroup 을 따로 컴포넌트로 만들고, 타입을 통해 name prop이 없으면 type error가 나게 하면 더 믿을만해질 것 입니다. 905 | 906 | 이러한 최신 CSS의 기능들은 tailwind를 비롯한 다양한 스타일링 라이브러리도 잘 지원합니다. 저도 tailwind로 시작해서, saas, styled-component, pandacss 등 다양한 도구를 사용했는데요. [테일윈드 문서의 Handling Hover, Focus, and Other States 파트](https://tailwindcss.com/docs/hover-focus-and-other-states)처럼 자신이 사용하는 라이브러리 이름 + aria 나 hover, focus, checked 등으로 검색하시면 필요한 지식을 얻으실 수 있을 겁니다. 907 | 908 | ### 목차 909 | 910 | - [하나. 브라우저 컴포넌트 테스트 환경 셋업하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/cosmos) 911 | - [둘. 목록과 필터 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/saas/SaasList) <- 지금 여기 912 | - [셋. 폼 컴포넌트 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/domains/member/MemberCreateForm) 913 | - [넷. 헤드리스 컴포넌트로 만든 디자인 시스템 테스트하기](https://github.com/taehee-sp/turing-frontend-test/tree/main/src/app/common/components/SelectWithCombobox) 914 | -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/SaasList.fixture.tsx: -------------------------------------------------------------------------------- 1 | import { SaasList } from "./SaasList"; 2 | import { 3 | 결재내역_없는_SaaS, 4 | 로고_결제내역_있는_SaaS, 5 | 로고_없는_SaaS, 6 | 이름만_있는_SaaS, 7 | } from "./SaasListItem.fixture"; 8 | 9 | export const testSaasList: { 10 | id: string; 11 | logoUrl: string | null; 12 | name: string; 13 | lastPaidAt: Date | null; 14 | }[] = [ 15 | 로고_결제내역_있는_SaaS, 16 | 결재내역_없는_SaaS, 17 | 로고_없는_SaaS, 18 | 이름만_있는_SaaS, 19 | { 20 | id: "80641d76-5a6a-42d0-84a0-495d7287ae27", 21 | name: "Asana", 22 | logoUrl: "https://asana.com/favicon.ico", 23 | lastPaidAt: new Date("2024-06-20"), 24 | }, 25 | ]; 26 | 27 | export default { 28 | "Saas가 없음": , 29 | "Saas가 여럿 있음": , 30 | }; 31 | -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/SaasList.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import SaasListStories from "./SaasList.fixture"; 3 | import { describe, test } from "vitest"; 4 | import { expectTL } from "@/siheom/expectTL"; 5 | import { queryTL } from "@/siheom/queryTL"; 6 | 7 | describe("SaasList", () => { 8 | test("SaaS가 없으면, 연동하세요 링크를 보여준다", async () => { 9 | render(SaasListStories["Saas가 없음"]); 10 | 11 | await expectTL(queryTL.link("연동하세요")).toHaveAttribute( 12 | "href", 13 | "/connect", 14 | ); 15 | }); 16 | test("결제 내역 있는 SaaS만 필터할 수 있다", async () => { 17 | render(SaasListStories["Saas가 여럿 있음"]); 18 | 19 | // given 전체 SaaS 필터 체크됨 20 | await expectTL(queryTL.radio("전체 5")).toBeChecked(); 21 | await expectTL(queryTL.list("SaaS 목록")).toBeVisible(); 22 | 23 | // given 최근 결제일 순으로 정렬됨 24 | await expectTL(queryTL.listitem("")).toHaveTextContents([ 25 | "Notion2024년 6월 27일 결제", 26 | "Asana2024년 6월 20일 결제", 27 | "GitHub2024년 6월 15일 결제", 28 | "Slack", 29 | "Zoom", 30 | ]); 31 | 32 | // when 결제 내역 있는 SaaS 라디오를 클릭하면 33 | await queryTL.radio("결제 내역 있는 SaaS 3").click(); 34 | 35 | // then 결제 내역 있는 SaaS만 필터됨 36 | await expectTL(queryTL.listitem("")).toHaveTextContents([ 37 | "Notion2024년 6월 27일 결제", 38 | "Asana2024년 6월 20일 결제", 39 | "GitHub2024년 6월 15일 결제", 40 | ]); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/SaasList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { VStack } from "@styled-system/jsx"; 3 | import { SaasListItem } from "./SaasListItem"; 4 | import { useState } from "react"; 5 | import { FilterList } from "@/app/common/components/FilterList/FilterList"; 6 | import { Link } from "@/app/routing/Link"; 7 | 8 | export const SaasList = ({ 9 | saasList, 10 | }: { 11 | saasList: { 12 | id: string; 13 | name: string; 14 | logoUrl: string | null; 15 | lastPaidAt: Date | null; 16 | }[]; 17 | }) => { 18 | const [selected, setSelected] = useState("all"); 19 | 20 | const saasWithPaymentList = saasList.filter( 21 | (saas) => saas.lastPaidAt instanceof Date, 22 | ); 23 | 24 | if (saasList.length === 0) { 25 | return 연동하세요; 26 | } 27 | 28 | return ( 29 | 30 | 42 | 43 |
      44 | {(selected === "all" ? saasList : saasWithPaymentList) 45 | .sort( 46 | (a, b) => 47 | (b.lastPaidAt?.valueOf() ?? 0) - (a.lastPaidAt?.valueOf() ?? 0), 48 | ) 49 | .map((saas) => ( 50 | 51 | ))} 52 |
    53 |
    54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/SaasListItem.fixture.tsx: -------------------------------------------------------------------------------- 1 | import { SaasListItem } from "./SaasListItem"; 2 | 3 | export const 로고_결제내역_있는_SaaS = { 4 | id: "ef4cdc19-a393-478f-8caf-9d1ab7f7a82e", 5 | name: "Notion", 6 | logoUrl: "https://assets-dev.smply.app/saas-logos/9-IepQ3Rrt.png", 7 | lastPaidAt: new Date("2024-06-27"), 8 | }; 9 | 10 | export const 결재내역_없는_SaaS = { 11 | id: "c54e160e-feb2-4545-b0a4-a2ed7f70e6d2", 12 | name: "Slack", 13 | logoUrl: 14 | "https://assets-global.website-files.com/621c8d7ad9e04933c4e51ffb/65eba5ffa14998827c92cc01_slack-octothorpe.png", 15 | lastPaidAt: null, 16 | }; 17 | 18 | export const 로고_없는_SaaS = { 19 | id: "a983512a-4daf-45b0-892d-d7e8399d982b", 20 | name: "GitHub", 21 | logoUrl: null, 22 | lastPaidAt: new Date("2024-06-15"), 23 | }; 24 | 25 | export const 이름만_있는_SaaS = { 26 | id: "469413ca-1c22-480a-8adc-7f6b00d17324", 27 | name: "Zoom", 28 | logoUrl: null, 29 | lastPaidAt: null, 30 | }; 31 | 32 | export default { 33 | "로고 결제내역 있는 SaaS": , 34 | "결재내역 없는 SaaS": , 35 | "로고 없는 SaaS": , 36 | "이름만 있는 SaaS": , 37 | }; 38 | -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/SaasListItem.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import SaasListItemStories, { 3 | 로고_결제내역_있는_SaaS, 4 | } from "./SaasListItem.fixture"; 5 | import { describe, test } from "vitest"; 6 | import { expectTL } from "@/siheom/expectTL"; 7 | import { queryTL } from "@/siheom/queryTL"; 8 | 9 | describe("SaasListItem", () => { 10 | test("이름만 있는 SaaS", async () => { 11 | render(SaasListItemStories["이름만 있는 SaaS"]); 12 | 13 | await expectTL(queryTL.heading("Zoom")).toBeVisible(); 14 | await expectTL(queryTL.text("결제")).not.toBeVisible(); 15 | }); 16 | 17 | test("로고 결제내역 있는 SaaS", async () => { 18 | render(SaasListItemStories["로고 결제내역 있는 SaaS"]); 19 | 20 | await expectTL(queryTL.heading("Notion")).toBeVisible(); 21 | await expectTL(queryTL.text("2024년 6월 27일 결제")).toBeVisible(); 22 | }); 23 | 24 | test("항목은 해당 SaaS의 상세 페이지로 가능 링크다", async () => { 25 | render(SaasListItemStories["로고 결제내역 있는 SaaS"]); 26 | 27 | await expectTL(queryTL.link("Notion")).toHaveAttribute( 28 | "href", 29 | `/saas/${로고_결제내역_있는_SaaS.id}`, 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/SaasListItem.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@/app/routing/Link"; 2 | import { css, cx } from "@styled-system/css"; 3 | import { box, hstack, square } from "@styled-system/patterns"; 4 | 5 | const SAAS_DEFAULT_IMAGE_URL = 6 | "https://dev.smply.app/img/icon-logos/placeholder.png"; 7 | 8 | function formatDate년월일(date: Date): string { 9 | return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`; 10 | } 11 | 12 | export const SaasListItem = ({ 13 | saas, 14 | }: { 15 | saas: { 16 | id: string; 17 | name: string; 18 | logoUrl: string | null; 19 | lastPaidAt: Date | null; 20 | }; 21 | }) => { 22 | const lastPaidAt = saas.lastPaidAt; 23 | return ( 24 |
  • 25 | 41 | 47 |

    {saas.name}

    48 | {lastPaidAt && ( 49 |

    55 | {formatDate년월일(lastPaidAt)} 결제 56 |

    57 | )} 58 | 59 |
  • 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehee-kim-sherpas/turing-frontend-test/eaf5b9da1152dfe510ed1dd25df438f35ba80f30/src/app/domains/saas/SaasList/expected.png -------------------------------------------------------------------------------- /src/app/domains/saas/SaasList/unstyled-no-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehee-kim-sherpas/turing-frontend-test/eaf5b9da1152dfe510ed1dd25df438f35ba80f30/src/app/domains/saas/SaasList/unstyled-no-image.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehee-kim-sherpas/turing-frontend-test/eaf5b9da1152dfe510ed1dd25df438f35ba80f30/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/index.css: -------------------------------------------------------------------------------- 1 | @layer reset, base, tokens, recipes, utilities; 2 | 3 | body {font-family: 'SUIT Variable', sans-serif;} -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./index.css"; 3 | import { ToastContainer } from "react-toastify"; 4 | import "react-toastify/dist/ReactToastify.css"; 5 | 6 | export const metadata: Metadata = { 7 | title: "튜링의 사과 프론트엔드 테스트 강의", 8 | description: 9 | "튜링의 사과에서 강의했던 프론트엔드 컴포넌트 테스트 강의 실습 예제", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | 20 | 24 | 25 | 26 | {children} 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { VStack } from "../../styled-system/jsx/vstack"; 3 | import { css } from "../../styled-system/css/css"; 4 | import { SaasList } from "./domains/saas/SaasList/SaasList"; 5 | import { testSaasList } from "./domains/saas/SaasList/SaasList.fixture"; 6 | 7 | async function getSaasList() { 8 | // fetch라면? 9 | // fetch("/customer/" + customerId +"/saas") 10 | // .then(res => res.json()) 11 | // .then(body => body.saasList) 12 | return testSaasList; 13 | } 14 | 15 | export default async function Home() { 16 | // react-query 라면? 17 | // const saasList = useSuspenseQuery(saasListOption()).data 18 | const saasList = await getSaasList(); 19 | 20 | return ( 21 |
    22 | 23 |

    turing frontend test

    24 |
    25 | 26 | 27 |
    28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/routing/Link.tsx: -------------------------------------------------------------------------------- 1 | import NextLink from "next/link"; 2 | import type { PropsWithChildren } from "react"; 3 | 4 | export const Link = ({ 5 | className, 6 | href, 7 | children, 8 | }: PropsWithChildren<{ className?: string; href: string }>) => { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/siheom/expectTL.ts: -------------------------------------------------------------------------------- 1 | import { waitFor } from "@testing-library/dom"; 2 | import { expect } from "vitest"; 3 | import { swapStackAsync, type TLocator } from "./queryTL"; 4 | 5 | const NonVisibleElement = document.createElement("div"); 6 | NonVisibleElement.ariaHidden = "true"; 7 | 8 | export function expectTL(tLocator: TLocator): { 9 | toBeChecked: () => Promise; 10 | toBeVisible: () => Promise; 11 | toBeDisabled: () => Promise; 12 | toBeCurrent: ( 13 | type: "page" | "step" | "location" | "date" | "time" | "true", 14 | ) => Promise; 15 | toBeExpanded: () => Promise; 16 | toBeSelected: () => Promise; 17 | toBeFocusable: () => Promise; 18 | toHaveText: (text: string) => Promise; 19 | toHaveValue: (value: string) => Promise; 20 | toHaveTextContents: (value: string[]) => Promise; 21 | toHaveCount: (count: number) => Promise; 22 | toHaveAttribute: (attribute: string, value: unknown) => Promise; 23 | toBeFocused: () => Promise; 24 | not: { 25 | toBeChecked: () => Promise; 26 | toBeVisible: () => Promise; 27 | toBeDisabled: () => Promise; 28 | toBeCurrent: ( 29 | type: "page" | "step" | "location" | "date" | "time" | "true" | "false", 30 | ) => Promise; 31 | toBeExpanded: () => Promise; 32 | toBeSelected: () => Promise; 33 | toBeFocusable: () => Promise; 34 | toHaveText: (text: string) => Promise; 35 | toHaveValue: (value: string) => Promise; 36 | toHaveTextContents: (value: string[]) => Promise; 37 | toHaveCount: (count: number) => Promise; 38 | toHaveAttribute: (attribute: string, value: unknown) => Promise; 39 | toBeFocused: () => Promise; 40 | }; 41 | } { 42 | return { 43 | async toBeChecked() { 44 | const fakeError = new Error(); 45 | return waitFor(() => { 46 | try { 47 | expect(tLocator.get()).toBeChecked(); 48 | } catch (error) { 49 | return swapStackAsync(fakeError, error); 50 | } 51 | }); 52 | }, 53 | async toBeVisible() { 54 | const fakeError = new Error(); 55 | return waitFor(() => { 56 | try { 57 | expect(tLocator.get()).toBeVisible(); 58 | } catch (error) { 59 | return swapStackAsync(fakeError, error); 60 | } 61 | }); 62 | }, 63 | async toBeDisabled() { 64 | const fakeError = new Error(); 65 | return waitFor(() => { 66 | try { 67 | expect(tLocator.get()).toHaveAttribute("aria-disabled", "true"); 68 | } catch (error) { 69 | return swapStackAsync(fakeError, error); 70 | } 71 | }); 72 | }, 73 | async toBeCurrent(type) { 74 | const fakeError = new Error(); 75 | return waitFor(() => { 76 | try { 77 | expect(tLocator.get()).toHaveAttribute("aria-current", type); 78 | } catch (error) { 79 | return swapStackAsync(fakeError, error); 80 | } 81 | }); 82 | }, 83 | async toBeExpanded() { 84 | const fakeError = new Error(); 85 | return waitFor(() => { 86 | try { 87 | expect(tLocator.get()).toHaveAttribute("aria-expanded", "true"); 88 | } catch (error) { 89 | return swapStackAsync(fakeError, error); 90 | } 91 | }); 92 | }, 93 | async toBeSelected() { 94 | const fakeError = new Error(); 95 | return waitFor(() => { 96 | try { 97 | expect(tLocator.get()).toHaveAttribute("aria-selected", "true"); 98 | } catch (error) { 99 | return swapStackAsync(fakeError, error); 100 | } 101 | }); 102 | }, 103 | async toBeFocusable() { 104 | const fakeError = new Error(); 105 | return waitFor(() => { 106 | try { 107 | expect(tLocator.get()).not.toHaveAttribute("tabindex", "-1"); 108 | } catch (error) { 109 | return swapStackAsync(fakeError, error); 110 | } 111 | }); 112 | }, 113 | async toHaveText(text) { 114 | const fakeError = new Error(); 115 | return waitFor(() => { 116 | try { 117 | expect(tLocator.get()).toHaveTextContent(text); 118 | } catch (error) { 119 | return swapStackAsync(fakeError, error); 120 | } 121 | }); 122 | }, 123 | async toHaveValue(value) { 124 | const fakeError = new Error(); 125 | return waitFor(() => { 126 | try { 127 | expect(tLocator.get()).toHaveValue(value); 128 | } catch (error) { 129 | return swapStackAsync(fakeError, error); 130 | } 131 | }); 132 | }, 133 | async toHaveTextContents(value) { 134 | const fakeError = new Error(); 135 | return waitFor(() => { 136 | try { 137 | expect(tLocator.getAll().map((el) => el.textContent)).toEqual(value); 138 | } catch (error) { 139 | return swapStackAsync(fakeError, error); 140 | } 141 | }); 142 | }, 143 | async toHaveAttribute(attribute, value) { 144 | const fakeError = new Error(); 145 | return waitFor(async () => { 146 | try { 147 | expect(tLocator.get()).toHaveAttribute(attribute, value); 148 | } catch (error) { 149 | return swapStackAsync(fakeError, error); 150 | } 151 | }); 152 | }, 153 | async toHaveCount(count) { 154 | const fakeError = new Error(); 155 | return waitFor(async () => { 156 | try { 157 | expect(await tLocator.findAll()).toHaveLength(count); 158 | } catch (error) { 159 | return swapStackAsync(fakeError, error); 160 | } 161 | }); 162 | }, 163 | 164 | async toBeFocused() { 165 | const fakeError = new Error(); 166 | return waitFor(() => { 167 | try { 168 | expect(tLocator.get()).toBe(document.activeElement); 169 | } catch (error) { 170 | return swapStackAsync(fakeError, error); 171 | } 172 | }); 173 | }, 174 | not: { 175 | async toBeChecked() { 176 | const fakeError = new Error(); 177 | return waitFor(() => { 178 | try { 179 | expect(tLocator.get()).not.toBeChecked(); 180 | } catch (error) { 181 | return swapStackAsync(fakeError, error); 182 | } 183 | }); 184 | }, 185 | async toBeVisible() { 186 | const fakeError = new Error(); 187 | return waitFor(() => { 188 | try { 189 | // null인 경우도 playwright와 같이 non visible로 처리 190 | expect(tLocator.query() ?? NonVisibleElement).not.toBeVisible(); 191 | } catch (error) { 192 | return swapStackAsync(fakeError, error); 193 | } 194 | }); 195 | }, 196 | async toBeDisabled() { 197 | const fakeError = new Error(); 198 | return waitFor(() => { 199 | try { 200 | expect(tLocator.get()).not.toHaveAttribute("aria-disabled", "true"); 201 | } catch (error) { 202 | return swapStackAsync(fakeError, error); 203 | } 204 | }); 205 | }, 206 | async toBeCurrent(type = "false") { 207 | const fakeError = new Error(); 208 | if (type === "false") { 209 | return waitFor(() => { 210 | try { 211 | expect(tLocator.get()).toHaveAttribute("aria-current", "false"); 212 | } catch (error) { 213 | return swapStackAsync(fakeError, error); 214 | } 215 | }); 216 | } 217 | return waitFor(() => { 218 | try { 219 | expect(tLocator.get()).not.toHaveAttribute("aria-current", type); 220 | } catch (error) { 221 | return swapStackAsync(fakeError, error); 222 | } 223 | }); 224 | }, 225 | async toBeExpanded() { 226 | const fakeError = new Error(); 227 | return waitFor(() => { 228 | try { 229 | expect(tLocator.get()).not.toHaveAttribute("aria-expanded", "true"); 230 | } catch (error) { 231 | return swapStackAsync(fakeError, error); 232 | } 233 | }); 234 | }, 235 | async toBeSelected() { 236 | const fakeError = new Error(); 237 | return waitFor(() => { 238 | try { 239 | expect(tLocator.get()).not.toHaveAttribute("aria-selected", "true"); 240 | } catch (error) { 241 | return swapStackAsync(fakeError, error); 242 | } 243 | }); 244 | }, 245 | async toBeFocusable() { 246 | const fakeError = new Error(); 247 | return waitFor(() => { 248 | try { 249 | expect(tLocator.get()).toHaveAttribute("tabindex", "-1"); 250 | } catch (error) { 251 | return swapStackAsync(fakeError, error); 252 | } 253 | }); 254 | }, 255 | async toHaveText(text) { 256 | const fakeError = new Error(); 257 | return waitFor(() => { 258 | try { 259 | expect(tLocator.get()).not.toHaveTextContent(text); 260 | } catch (error) { 261 | return swapStackAsync(fakeError, error); 262 | } 263 | }); 264 | }, 265 | async toHaveValue(value) { 266 | const fakeError = new Error(); 267 | return waitFor(() => { 268 | try { 269 | expect(tLocator.get()).not.toHaveValue(value); 270 | } catch (error) { 271 | return swapStackAsync(fakeError, error); 272 | } 273 | }); 274 | }, 275 | async toHaveTextContents(value) { 276 | const fakeError = new Error(); 277 | return waitFor(() => { 278 | try { 279 | expect(tLocator.getAll().map((el) => el.textContent)).not.toEqual( 280 | value, 281 | ); 282 | } catch (error) { 283 | return swapStackAsync(fakeError, error); 284 | } 285 | }); 286 | }, 287 | async toHaveAttribute(attribute, value) { 288 | const fakeError = new Error(); 289 | return waitFor(async () => { 290 | try { 291 | expect(tLocator.get()).not.toHaveAttribute(attribute, value); 292 | } catch (error) { 293 | return swapStackAsync(fakeError, error); 294 | } 295 | }); 296 | }, 297 | async toHaveCount(count) { 298 | const fakeError = new Error(); 299 | return waitFor(async () => { 300 | try { 301 | expect(await tLocator.findAll()).not.toHaveLength(count); 302 | } catch (error) { 303 | return swapStackAsync(fakeError, error); 304 | } 305 | }); 306 | }, 307 | async toBeFocused() { 308 | const fakeError = new Error(); 309 | return waitFor(() => { 310 | try { 311 | expect(tLocator.get()).not.toBe(document.activeElement); 312 | } catch (error) { 313 | return swapStackAsync(fakeError, error); 314 | } 315 | }); 316 | }, 317 | }, 318 | }; 319 | } 320 | -------------------------------------------------------------------------------- /src/siheom/getA11ySnapshot.ts: -------------------------------------------------------------------------------- 1 | export function getA11ySnapshot(element: HTMLElement) { 2 | function getAriaRole(el: HTMLElement) { 3 | return ( 4 | el.getAttribute("role") || 5 | { 6 | h1: "heading", 7 | h2: "heading", 8 | h3: "heading", 9 | h4: "heading", 10 | h5: "heading", 11 | h6: "heading", 12 | ul: "list", 13 | ol: "list", 14 | li: "listitem", 15 | a: "link", 16 | button: "button", 17 | input: "textbox", 18 | img: "img", 19 | table: "table", 20 | }[el.tagName.toLowerCase()] || 21 | "" 22 | ); 23 | } 24 | 25 | function getAccessibleName(el: HTMLElement) { 26 | if (el.hasAttribute("alt")) return el.getAttribute("alt"); 27 | if (el.hasAttribute("aria-label")) return el.getAttribute("aria-label"); 28 | if (el.hasAttribute("aria-labelledby")) { 29 | const labelId = el.getAttribute("aria-labelledby"); 30 | 31 | if (labelId) { 32 | const labelEl = document.getElementById(labelId); 33 | if (labelEl) return labelEl.textContent?.trim() ?? ""; 34 | } 35 | } 36 | if (el.id) { 37 | const labelEl = document.querySelector(`label[for="${el.id}"]`); 38 | if (labelEl) return labelEl.textContent?.trim() ?? ""; 39 | } 40 | 41 | // https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/#namingtechniques 42 | if ( 43 | [ 44 | "button", 45 | "cell", 46 | "checkbox", 47 | "columnheader", 48 | "gridcell", 49 | "heading", 50 | "link", 51 | "menuitem", 52 | "menuitemcheckbox", 53 | "menuitemradio", 54 | "option", 55 | "radio", 56 | "row", 57 | "rowheader", 58 | "switch", 59 | "tab", 60 | "tooltip", 61 | ].includes(getAriaRole(el)) 62 | ) { 63 | return el.textContent?.trim() ?? ""; 64 | } 65 | return ""; 66 | } 67 | 68 | function processElement(el: HTMLElement, depth = 0) { 69 | if (el.ariaHidden || el.hidden) return ""; 70 | const role = getAriaRole(el); 71 | 72 | if (role === "presentation") return ""; 73 | 74 | const name = getAccessibleName(el); 75 | let result = role 76 | ? `${" ".repeat(depth) + role + (name ? `: ${name}` : "")}\n` 77 | : ""; 78 | 79 | for (const child of Array.from(el.children)) { 80 | if (child instanceof HTMLElement) { 81 | result += processElement(child, depth + (role ? 1 : 0)); 82 | } 83 | } 84 | 85 | return result; 86 | } 87 | 88 | return processElement(element).trim(); 89 | } 90 | -------------------------------------------------------------------------------- /src/siheom/getTableMarkdown.ts: -------------------------------------------------------------------------------- 1 | export function htmlTableToMarkdown(tableElement: HTMLTableElement) { 2 | // Get all rows including header 3 | const allRows = [ 4 | ...Array.from(tableElement.querySelectorAll("thead tr")), 5 | ...Array.from(tableElement.querySelectorAll("tbody tr")), 6 | ]; 7 | 8 | // Extract cell contents 9 | const cellContents = allRows.map((row) => 10 | Array.from(row.querySelectorAll("th, td")).map( 11 | (cell) => cell.textContent?.trim() ?? "", 12 | ), 13 | ); 14 | 15 | // Calculate max width for each column 16 | const columnWidths = cellContents[0].map((_, colIndex) => 17 | Math.max(...cellContents.map((row) => row[colIndex].length)), 18 | ); 19 | 20 | // Pad cells and create markdown 21 | let markdown = ""; 22 | 23 | cellContents.forEach((row, rowIndex) => { 24 | const paddedRow = row.map((cell, cellIndex) => 25 | cell.padStart(columnWidths[cellIndex]).padEnd(columnWidths[cellIndex]), 26 | ); 27 | 28 | markdown += `| ${paddedRow.join(" | ")} |\n`; 29 | 30 | // Add separator after header 31 | if (rowIndex === 0) { 32 | markdown += `| ${columnWidths.map((width) => "-".repeat(width)).join(" | ")} |\n`; 33 | } 34 | }); 35 | 36 | return markdown; 37 | } 38 | -------------------------------------------------------------------------------- /src/siheom/queryTL.ts: -------------------------------------------------------------------------------- 1 | import { within } from "@testing-library/dom"; 2 | import userEvent from "@testing-library/user-event"; 3 | 4 | // https://main.vitest.dev/guide/browser#context 5 | // import { userEvent } from '@vitest/browser/context'; 6 | 7 | function safeFromEntries(entries: [K, V][]) { 8 | return Object.fromEntries(entries) as { 9 | [k in K]: V; 10 | }; 11 | } 12 | 13 | export function swapStackAsync(fakeError: Error, error: unknown) { 14 | if (error instanceof Error) { 15 | const lines = fakeError.stack?.split("\n") ?? []; 16 | const fail = error.stack?.split("\n").slice(0, 5) ?? []; 17 | error.stack = [...fail, lines[0], ...lines.slice(2)].join("\n"); 18 | // console.log(error.stack); 19 | return Promise.reject(error); 20 | } 21 | return Promise.reject(error); 22 | } 23 | 24 | export function swapStackSync(fakeError: Error, error: unknown) { 25 | if (error instanceof Error) { 26 | const lines = fakeError.stack?.split("\n") ?? []; 27 | const fail = error.stack?.split("\n").slice(0, 5) ?? []; 28 | error.stack = [...fail, lines[0], ...lines.slice(2)].join("\n"); 29 | // console.log(error.stack); 30 | return error; 31 | } 32 | return error; 33 | } 34 | 35 | export type TLocator = { 36 | click(options?: Parameters[1]): Promise; 37 | fill( 38 | text: string, 39 | options?: Parameters[2], 40 | ): Promise; 41 | clear(): Promise; 42 | waitFor(): Promise; 43 | find(): Promise; 44 | findAll(): Promise; 45 | get(): HTMLElement; 46 | getAll(): HTMLElement[]; 47 | query(): HTMLElement | null; 48 | } & ReturnType; 49 | 50 | const ARIAWidgetRole = [ 51 | "button", 52 | "checkbox", 53 | "gridcell", 54 | "link", 55 | "menuitem", 56 | "menuitemcheckbox", 57 | "menuitemradio", 58 | "option", 59 | "progressbar", 60 | "radio", 61 | "scrollbar", 62 | "searchbox", 63 | "slider", 64 | "spinbutton", 65 | "switch", 66 | "tab", 67 | "tabpanel", 68 | "textbox", 69 | "treeitem", 70 | ] as const; 71 | 72 | const ARIACompositeWidgetRole = [ 73 | "combobox", 74 | "grid", 75 | "listbox", 76 | "menu", 77 | "menubar", 78 | "radiogroup", 79 | "tablist", 80 | "tree", 81 | "treegrid", 82 | ] as const; 83 | 84 | const ARIADocumentStructureRole = [ 85 | "application", 86 | "article", 87 | "blockquote", 88 | "caption", 89 | "cell", 90 | "columnheader", 91 | "definition", 92 | "deletion", 93 | "directory", 94 | "document", 95 | "emphasis", 96 | "feed", 97 | "figure", 98 | "generic", 99 | "group", 100 | "heading", 101 | "img", 102 | "insertion", 103 | "list", 104 | "listitem", 105 | "math", 106 | "meter", 107 | "none", 108 | "note", 109 | "paragraph", 110 | "presentation", 111 | "row", 112 | "rowgroup", 113 | "rowheader", 114 | "separator", 115 | "strong", 116 | "subscript", 117 | "superscript", 118 | "table", 119 | "term", 120 | "time", 121 | "toolbar", 122 | "tooltip", 123 | ] as const; 124 | 125 | const ARIALiveRegionRole = [ 126 | "alert", 127 | "log", 128 | "marquee", 129 | "status", 130 | "timer", 131 | ] as const; 132 | 133 | const ARIAWindowRole = ["alertdialog", "dialog"] as const; 134 | 135 | const ARIALandmarkRole = [ 136 | "banner", 137 | "complementary", 138 | "contentinfo", 139 | "form", 140 | "main", 141 | "navigation", 142 | "region", 143 | "search", 144 | ] as const; 145 | export const roles = [ 146 | ...ARIACompositeWidgetRole, 147 | ...ARIADocumentStructureRole, 148 | ...ARIALandmarkRole, 149 | ...ARIAWidgetRole, 150 | ...ARIALiveRegionRole, 151 | ...ARIAWindowRole, 152 | ]; 153 | 154 | export function createQueryTL(getBaseElement = () => document.body) { 155 | const base = () => within(getBaseElement()); 156 | const query = safeFromEntries( 157 | roles.map((role) => [ 158 | role, 159 | (_name?: string | RegExp, exact = false) => { 160 | const name = _name 161 | ? exact || _name instanceof RegExp 162 | ? _name 163 | : new RegExp(_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") 164 | : undefined; 165 | const result: TLocator = { 166 | async click(options) { 167 | const fakeError = new Error(); 168 | try { 169 | await base() 170 | .findByRole(role, { name }) 171 | .then(($el) => userEvent.click($el, options)); 172 | } catch (error) { 173 | return swapStackAsync(fakeError, error); 174 | } 175 | }, 176 | async fill(text, options) { 177 | const fakeError = new Error(); 178 | try { 179 | await base() 180 | .findByRole(role, { name }) 181 | .then(async ($el) => { 182 | await userEvent.clear($el); 183 | await userEvent.type($el, text, options); 184 | }); 185 | } catch (error) { 186 | return swapStackAsync(fakeError, error); 187 | } 188 | }, 189 | async clear() { 190 | const fakeError = new Error(); 191 | try { 192 | await base() 193 | .findByRole(role, { name }) 194 | .then(($el) => userEvent.clear($el)); 195 | } catch (error) { 196 | return swapStackAsync(fakeError, error); 197 | } 198 | }, 199 | async waitFor() { 200 | const fakeError = new Error(); 201 | try { 202 | await base().findByRole(role, { name }, { timeout: 2000 }); 203 | } catch (error) { 204 | return swapStackAsync(fakeError, error); 205 | } 206 | }, 207 | find: async () => { 208 | const fakeError = new Error(); 209 | try { 210 | return await base().findByRole(role, { name }); 211 | } catch (error) { 212 | return swapStackAsync(fakeError, error); 213 | } 214 | }, 215 | findAll: async () => { 216 | const fakeError = new Error(); 217 | try { 218 | return await base().findAllByRole(role, { name }); 219 | } catch (error) { 220 | return swapStackAsync(fakeError, error); 221 | } 222 | }, 223 | get: () => { 224 | const fakeError = new Error(); 225 | try { 226 | return base().getByRole(role, { name }); 227 | } catch (error) { 228 | throw swapStackSync(fakeError, error); 229 | } 230 | }, 231 | getAll: () => { 232 | const fakeError = new Error(); 233 | try { 234 | return base().getAllByRole(role, { name }); 235 | } catch (error) { 236 | throw swapStackSync(fakeError, error); 237 | } 238 | }, 239 | query: () => { 240 | const fakeError = new Error(); 241 | try { 242 | return base().queryByRole(role, { name }); 243 | } catch (error) { 244 | throw swapStackSync(fakeError, error); 245 | } 246 | }, 247 | ...createQueryTL(() => base().getByRole(role, { name })), 248 | }; 249 | return result; 250 | }, 251 | ]), 252 | ); 253 | 254 | return { 255 | ...query, 256 | text: (_text: string | RegExp, exact = false) => { 257 | const text = _text 258 | ? exact || _text instanceof RegExp 259 | ? _text 260 | : new RegExp(_text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") 261 | : ""; 262 | const find = () => base().findByText(text); 263 | const result: TLocator = { 264 | async click(options) { 265 | return find().then(($el) => userEvent.click($el, options)); 266 | }, 267 | async fill(text, options) { 268 | return find().then(async ($el) => { 269 | await userEvent.clear($el); 270 | await userEvent.type($el, text, options); 271 | }); 272 | }, 273 | async clear() { 274 | return find().then(($el) => userEvent.clear($el)); 275 | }, 276 | async waitFor() { 277 | return find().then(() => undefined); 278 | }, 279 | find, 280 | findAll: () => base().findAllByText(text), 281 | get: () => base().getByText(text), 282 | getAll: () => base().getAllByText(text), 283 | query: () => base().queryByText(text), 284 | ...createQueryTL(() => base().getByText(text)), 285 | }; 286 | return result; 287 | }, 288 | }; 289 | } 290 | 291 | export const queryTL = createQueryTL(); 292 | -------------------------------------------------------------------------------- /src/siheom/renderWithContext.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import type { ReactNode } from "react"; 3 | import { ToastContainer } from "react-toastify"; 4 | import "react-toastify/dist/ReactToastify.css"; 5 | 6 | export function renderWithContext(element: ReactNode) { 7 | return render( 8 | <> 9 | {element} 10 | 11 | , 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"], 22 | "@styled-system/*": ["./styled-system/*"] 23 | }, 24 | "types": ["@vitest/browser/providers/playwright"] 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "**/**/*.ts", "**/**/*.tsx"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import react from "@vitejs/plugin-react"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | import svgr from "vite-plugin-svgr"; 5 | /// 6 | 7 | export default defineConfig({ 8 | plugins: [tsconfigPaths(), react(), svgr({})], 9 | define: { 10 | "process.env": process.env, 11 | }, 12 | root: "./", 13 | test: { 14 | setupFiles: "./setupTests.ts", 15 | include: ["src/**/*.test.tsx", "src/**/*.test.ts"], 16 | css: true, 17 | pool: "vmThreads", 18 | poolOptions: { 19 | useAtomics: true, 20 | }, 21 | environment: 'happy-dom', 22 | browser: { 23 | enabled: true, 24 | name: "chromium", 25 | headless: true, 26 | provider: "playwright", 27 | }, 28 | testTimeout: 3000, 29 | coverage: { 30 | provider: "istanbul", 31 | include: ["src/**/*.{ts,tsx}"], 32 | exclude: [ 33 | "node_modules/**", 34 | "dist/**", 35 | "src/**/*.{stories,fixture}.{ts,tsx}", 36 | "src/**/*.{test,spec}.{ts,tsx}", 37 | "src/app/page.tsx", 38 | "src/app/layout.tsx", 39 | "src/app/cosmos", 40 | "src/siheom", 41 | ], 42 | all: true, 43 | reporter: ["json", "text", "html"], 44 | }, 45 | }, 46 | }); 47 | --------------------------------------------------------------------------------