├── .gitignore ├── README.md ├── docs ├── .netlify │ └── state.json ├── .nojekyll ├── 01-javascript-testing.md ├── 02-tdd-introduction.md ├── 03-react-test.md ├── 04-enzyme.md ├── 05-react-testing-library.md ├── 06-rtl-tdd.md ├── 07-async-test.md ├── 08-redux-test.md ├── README.md ├── _sidebar.md ├── index.html └── reference.md ├── javascript ├── jest.config.js ├── package.json ├── stats.js ├── stats.test.js ├── sum.js ├── sum.test.js └── yarn.lock ├── react-enzyme-test ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── Counter.js │ ├── Counter.test.js │ ├── HookCounter.js │ ├── HookCounter.test.js │ ├── Profile.js │ ├── Profile.test.js │ ├── __snapshots__ │ │ ├── Counter.test.js.snap │ │ ├── HookCounter.test.js.snap │ │ └── Profile.test.js.snap │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── serviceWorker.js │ └── setupTests.js └── yarn.lock ├── redux-test-tutorial ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── components │ │ ├── Counter.js │ │ └── Counter.test.js │ ├── containers │ │ ├── CounterContainer.js │ │ └── CounterContainer.test.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── modules │ │ ├── counter.js │ │ ├── counter.test.js │ │ └── index.js │ ├── renderWithRedux.js │ ├── serviceWorker.js │ └── setupTests.js └── yarn.lock ├── rtl-tdd-todos ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── TodoApp.js │ ├── TodoApp.test.js │ ├── TodoForm.js │ ├── TodoForm.test.js │ ├── TodoItem.js │ ├── TodoItem.test.js │ ├── TodoList.js │ ├── TodoList.test.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── serviceWorker.js │ └── setupTests.js └── yarn.lock └── rtl-tutorial ├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.js ├── App.test.js ├── Counter.js ├── Counter.test.js ├── DelayedToggle.js ├── DelayedToggle.test.js ├── Profile.js ├── Profile.test.js ├── UserProfile.js ├── UserProfile.test.js ├── __snapshots__ │ ├── Counter.test.js.snap │ └── Profile.test.js.snap ├── index.css ├── index.js ├── logo.svg ├── serviceWorker.js └── setupTests.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 벨로퍼트와 함께하는 리액트 테스팅 2 | 3 | 튜토리얼 문서: http://learn-react-test.vlpt.us/ 4 | -------------------------------------------------------------------------------- /docs/.netlify/state.json: -------------------------------------------------------------------------------- 1 | { 2 | "siteId": "780b9698-34d6-410f-b40c-412c9bf8b343" 3 | } -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/learn-react-testing/6c006c222684ac0048c3dcb14fc4bf3bdaf77c3b/docs/.nojekyll -------------------------------------------------------------------------------- /docs/01-javascript-testing.md: -------------------------------------------------------------------------------- 1 | # 1. 자바스크립트 테스팅의 기초 2 | 3 | 자바스크립트로 작성된 프로젝트에 테스트 자동화를 사용 할 때 사용 할 수 있는 도구는 다양합니다. 리스팅을 해보자면 다음과 같습니다. 4 | 5 | - Karma 6 | - Jasmine 7 | - Jest 8 | - Chai 9 | - Mocha 10 | 11 | 종류가 정말 다양하지요? 이 도구들은 비슷한 작업을 처리하지만 각각 다른 특성들을 가지고 있습니다. 각 도구들의 차이점들을 보고 싶으시다면 이 [링크](https://medium.com/welldone-software/an-overview-of-javascript-testing-in-2019-264e19514d0a)를 읽어보시면 도움 이 될 수 있습니다. 12 | 13 | 이 튜토리얼에서는, 설정이 간단하고 시작하기 편한 Jest 를 사용하겠습니다. Jest 는 페이스북 팀에서 Jasmine 기반으로 만든 테스팅 프레임워크입니다. CRA 로 만든 프로젝트에는 자동으로 적용이 되어있습니다. 14 | 15 | ## 작업환경 설정 16 | 17 | 리액트에서 테스트 코드를 작성해보기전에, 간단한 자바스크립트 함수들의 테스트 코드를 작성해보겠습니다. 18 | 19 | 새로운 디렉터리를 만들고, 그 안에서 `yarn init -y` 명령어 (혹은 `npm init -y`) 를 입력하여 새 자바스크립트 프로젝트를 생성하세요. 20 | 21 | 그 안에서, jest 를 설치하세요. 22 | 23 | ```bash 24 | $ yarn add jest 25 | # 혹은 npm install --save jest 26 | ``` 27 | 28 | > 여러분이 만약 VS Code 를 사용하신다면, @types/jest 도 설치하시면 VS Code 에서 인텔리센스 지원을 받을 수 있습니다. 29 | > 30 | > ```bash 31 | > $ yarn add @types/jest 32 | > ``` 33 | 34 | ## 첫번째 테스트 작성하기 35 | 36 | 이제, sum.js 라는 파일을 해당 디렉터리에 만들어보세요. 37 | 38 | #### `sum.js` 39 | 40 | ```javascript 41 | function sum(a, b) { 42 | return a + b; 43 | } 44 | 45 | module.exports = sum; // 내보내기 46 | ``` 47 | 48 | 진짜 간단한 함수죠? 이게 정말 잘 작동하는지 테스트 코드를 작성해보겠습니다. 같은 디렉터리에 sum.test.js 를 작성해보세요. 49 | 50 | #### `sum.test.js` 51 | 52 | ```javascript 53 | const sum = require('./sum'); 54 | 55 | test('1 + 2 = 3', () => { 56 | expect(sum(1, 2)).toBe(3); 57 | }); 58 | ``` 59 | 60 | 여기서 사용한 `test` 라는 함수는, 새로운 테스트 케이스를 만드는 함수입니다. 그리고 `expect`는 특정 값이 ~~ 일 것이다 라고 사전에 정의를 하고, 통과를 하면 테스트를 성공시키고 통과를 하지 않으면 테스트를 실패시킵니다. `toBe` 는 [matchers](https://jestjs.io/docs/en/using-matchers) 라고 부르는 함수인데요, 특정 값이 어떤 조건을 만족하는지, 또는 어떤 함수가 실행이 됐는지, 에러가 났는지 등을 확인 할 수 있게 해줍니다. 여기서 toBe 는 특정 값이 우리가 정한 값과 일치하는지 확인을 해줍니다. 61 | 62 | 그리고 나서 jest 를 실행해보겠습니다. package.json 에 `scripts` 를 다음과 같이 추가하세요. 63 | 64 | ```javascript 65 | { 66 | "name": "javascript", 67 | "version": "1.0.0", 68 | "main": "index.js", 69 | "license": "MIT", 70 | "dependencies": { 71 | "@types/jest": "^24.0.13", 72 | "jest": "^24.8.0" 73 | }, 74 | "scripts": { 75 | "test": "jest --watchAll --verbose" 76 | } 77 | } 78 | ``` 79 | 80 | 그 다음엔 터미널에서 방금 추가한 test 스크립트를 실행하세요. 81 | 82 | ```bash 83 | $ yarn test # 혹은 npm test 84 | ``` 85 | 86 | ![](https://i.imgur.com/v8i6iVM.png) 87 | 88 | 이런 결과물이 나타났나요? 89 | 90 | 한번 sum 함수를 다음과 같이 이상하게 수정해봅시다. 91 | 92 | #### `sum.js` 93 | 94 | ```javascript 95 | function sum(a, b) { 96 | return a - b; 97 | } 98 | 99 | module.exports = sum; // 내보내기 100 | ``` 101 | 102 | sum 에서는 값을 더해야되는데, 빼기를 해줬죠? 어떤 결과가 나타나는지 볼까요? 103 | 104 | ![](https://i.imgur.com/fsflqys.png) 105 | 106 | 우리가 의도한대로 작동을 하지 않으면, 이렇게 오류가 납니다. 오류를 확인했으면 다시 `a + b` 로 고쳐주세요. 107 | 108 | ## test 대신 it 109 | 110 | 우리가 새로운 테스트 케이스를 만들 때, `test` 라는 키워드를 사용했는데요, 이 키워드 말고 `it` 이라는 키워드를 사용 할 수도 있습니다. 작동방식은 완전히 똑같습니다. `it`을 사용하게 되면 테스트 케이스 설명을 영어로 작성하게 되는 경우, "말이 되게" 작성 할 수 있습니다. 111 | 112 | #### `sum.test.js` 113 | 114 | ```javascript 115 | const sum = require('./sum'); 116 | 117 | it('calculates 1 + 2', () => { 118 | expect(sum(1, 2)).toBe(3); 119 | }); 120 | ``` 121 | 122 | 테스트 케이스의 설명은 한국어로 적어도 상관은 없습니다. 영어로도 충분히 설명 할 수 있으면 영어로 하는게 좋겠지만, 한국어로 사용 할 때 더 쉽게 이해 할 수 있는 설명이라면 한국어로 작성하는것이 좋습니다. 123 | 124 | #### `sum.test.js` 125 | 126 | ```javascript 127 | const sum = require('./sum'); 128 | 129 | it('1 + 2 잘 더해진다', () => { 130 | expect(sum(1, 2)).toBe(3); 131 | }); 132 | ``` 133 | 134 | ## describe 를 사용해서 여러 테스트 케이스를 묶기 135 | 136 | 우리가 테스트 케이스를 작성 할 때 `describe` 라는 키워드를 사용하면 여러 테스트 케이스를 묶을 수 있습니다. 먼저 sum.js 에 배열의 총합을 구해주는 `sumOf` 를 구현해봅시다. 137 | 138 | #### sum.js 139 | 140 | ```javascript 141 | function sum(a, b) { 142 | return a + b; 143 | } 144 | 145 | function sumOf(numbers) { 146 | let result = 0; 147 | numbers.forEach(n => { 148 | result += n; 149 | }); 150 | return result; 151 | } 152 | 153 | // 각각 내보내기 154 | exports.sum = sum; 155 | exports.sumOf = sumOf; 156 | ``` 157 | 158 | 현재 여러 함수를 내보내고 있기 때문에 테스트 케이스가 망가졌을 것입니다. 다음과 같이 테스트 코드를 수정하세요. 159 | 160 | #### `sum.test.js` 161 | 162 | ```javascript 163 | const { sum, sumOf } = require('./sum'); 164 | 165 | describe('sum', () => { 166 | it('calculates 1 + 2', () => { 167 | expect(sum(1, 2)).toBe(3); 168 | }); 169 | 170 | it('calculates all numbers', () => { 171 | const array = [1, 2, 3, 4, 5]; 172 | expect(sumOf(array)).toBe(15); 173 | }); 174 | }); 175 | ``` 176 | 177 | 이렇게 describe 로 감싸주고 나면, 여러 테스트 케이스가 sum 이라는 이름으로 분류됩니다. 178 | 179 | ![](https://i.imgur.com/CP1J77P.png) 180 | 181 | ## 리팩토링 182 | 183 | 테스트 코드를 작성 했을 때 얻을 수 있는 이점은, 리팩토링 이후 코드가 제대로 작동하고 있는 것을 검증하기 매우 간편하다는 것 입니다. 한번 `sumOf` 함수를 다음과 같이 리팩토링해보세요. 184 | 185 | #### `sum.js` 186 | 187 | ```javascript 188 | function sum(a, b) { 189 | return a + b; 190 | } 191 | 192 | function sumOf(numbers) { 193 | return numbers.reduce((acc, current) => acc + current, 0); 194 | } 195 | 196 | // 각각 내보내기 197 | exports.sum = sum; 198 | exports.sumOf = sumOf; 199 | ``` 200 | 201 | 배열 내장함수 `reduce` 를 사용해서 배열의 총합을 구해주었습니다. 만약에 여기서 우리가 실수를 했었더라면? 테스트 케이스가 실패하여 바로 알 수 있겠죠? 202 | -------------------------------------------------------------------------------- /docs/02-tdd-introduction.md: -------------------------------------------------------------------------------- 1 | # 2. TDD 의 소개 2 | 3 | TDD (Test Driven Development · 테스트 주도 개발) 에 대해서 알아봅시다! TDD 는 테스트가 개발을 이끌어 나가는 형태의 개발론입니다. 4 | 가장 쉽게 설명하자면, 선 테스트 코드 작성, 후 구현 인데요, 이는 총 3가지 주요 절차로 이루어져있습니다. 5 | 6 | ![](https://i.imgur.com/wcbaeLC.png) 7 | 8 | ## TDD 의 3가지 절차 9 | 10 | ### 실패 11 | 12 | 첫번째 절차는 실패입니다. 이는, 실패하는 테스트 케이스를 먼저 만들라는 것 입니다. 실패하는 테스트 케이스를 만들 때는 프로젝트의 전체 기능에 대하여 처음부터 모든 테스트 케이스를 작성하는 것이 아니라, 지금 가장 먼저 구현할 기능 하나씩 테스트 케이스를 작성합니다. 13 | 14 | > 개발팀/상황에 따라 한꺼번에 여러 테스트 케이스를 먼저 작성하기도 합니다. 15 | 16 | ### 성공 17 | 18 | 두번째 절차는 성공입니다. 우리가 작성하는 실패하는 테스트 케이스를 통과시키기 위하여, 코드를 작성하여 테스트를 통과시키는 것 입니다. 19 | 20 | ### 리팩토링 21 | 22 | 세번째 절차는 리팩토링입니다. 우리가 구현한 코드에 중복되는 코드가 있거나, 혹은 더 개선시킬 방법이 있다면 리팩토링을 진행합니다. 리팩토링을 진행하고 나서도 테스트 케이스가 성공하는지 확인합니다. 이 절차가 끝났다면, 다시 첫번째 절차로 돌아가서 다음 기능 구현을 위하여 새로운 실패하는 테스트 케이스를 작성하세요. 23 | 24 | ## TDD 의 장점 25 | 26 | TDD 를 진행하면서 테스트 케이스를 작성할때 주로 작은 단위로 만들기 때문에, 코드를 작성 할 때 코드가 너무 방대해지지 않고, 코드의 모듈화가 자연스럽게 잘 이루어지면서 개발이 진행됩니다. 27 | 28 | TDD 를 하면 자연스레 테스트 커버리지가 높아질 수 밖에 없습니다. 테스트를 먼저 작성을 하고 구현을 하니까요. 테스트 커버리지가 높아지면 결국 리팩토링도 쉬워지고 유지보수도 쉬워집니다. 결국 프로젝트의 퀄리티를 높이기에 좋은 환경이 구성됩니다. 추가적으로, 협업을 할때도 매우 도움이 되지요. 29 | 30 | 그리고, 버그에 낭비하는 시간도 최소한으로 할 수 있고 우리가 구현한 기능이 요구사항을 충족하는지 쉽게 확인 할 수 있습니다. 31 | 32 | ## TDD 연습 33 | 34 | 이번 연습에서는 배열이 주어졌을 때 최댓값, 최솟값, 평균, 중앙값, 최빈값을 구하는 함수들을 구현해보겠습니다. 35 | 36 | 먼저 파일 두개를 만들어주세요: 37 | 38 | - stats.js 39 | - stats.test.js 40 | 41 | ### 최댓값 구하기 42 | 43 | 먼저 최댓값을 구하는 테스트케이스를 작성해봅시다. 44 | 45 | #### `stats.test.js` 46 | 47 | ```javascript 48 | const stats = require('./stats'); 49 | 50 | describe('stats', () => { 51 | it('gets maximum value', () => { 52 | expect(stats.max([1, 2, 3, 4])).toBe(4); 53 | }); 54 | }); 55 | ``` 56 | 57 | `stats.max` 함수가 존재하지 않으니 _TypeError: stats.max is not a function_ 이런 오류가 뜰 것입니다. 그럼, `max` 함수를 stats.js 에 구현해봅시다. 58 | 59 | #### `stats.js` 60 | 61 | ```javascript 62 | exports.max = numbers => { 63 | let result = numbers[0]; 64 | numbers.forEach(n => { 65 | if (n > result) { 66 | result = n; 67 | } 68 | }); 69 | return result; 70 | }; 71 | ``` 72 | 73 | 이렇게 하고 나면 테스트 케이스가 통과하겠죠? 그 다음엔 이 코드를 어떻게 하면 리팩토링 할 수 있을까.. 고민해봅시다. JavaScript 를 잘 활용할줄 아신다면 왜 이걸 이렇게 구현했나 의아해 하실정도로, 더 쉬운 방법이 있습니다. 74 | 75 | 바로 `Math.max` 함수를 사용하는 것이죠. 76 | 77 | #### `stats.js` 78 | 79 | ```javascript 80 | exports.max = numbers => Math.max(...numbers); 81 | ``` 82 | 83 | 리팩토링 끝! 84 | 85 | ### 최솟값 구하기 86 | 87 | 먼저 실패하는 테스트 케이스를 작성합니다. 88 | 89 | #### `stats.test.js` 90 | 91 | ```javascript 92 | const stats = require('./stats'); 93 | 94 | describe('stats', () => { 95 | it('gets maximum value', () => { 96 | expect(stats.max([1, 2, 3, 4])).toBe(4); 97 | }); 98 | it('gets minimum value', () => { 99 | expect(stats.min([1, 2, 3, 4])).toBe(1); 100 | }); 101 | }); 102 | ``` 103 | 104 | 그 다음에는 이 테스트 케이스를 통과시켜봅시다. 105 | 106 | #### `stats.js` 107 | 108 | ```javascript 109 | exports.max = numbers => Math.max(...numbers); 110 | exports.min = numbers => Math.min(...numbers); 111 | ``` 112 | 113 | 테스트 코드가 통과됐나요? 여기서 딱히 리팩토링 할 방법은 없으니 리팩토링 절차는 생략하겠습니다. 114 | 115 | ### 평균값 구하기 116 | 117 | 이번에는 평균값을 구해봅시다! 우선 평균값을 구하는 테스트 케이스를 작성해보세요. 118 | 119 | #### `stats.test.js` 120 | 121 | ```javascript 122 | const stats = require('./stats'); 123 | 124 | describe('stats', () => { 125 | it('gets maximum value', () => { 126 | expect(stats.max([1, 2, 3, 4])).toBe(4); 127 | }); 128 | it('gets minimum value', () => { 129 | expect(stats.min([1, 2, 3, 4])).toBe(1); 130 | }); 131 | it('gets average value', () => { 132 | expect(stats.avg([1, 2, 3, 4, 5])).toBe(3); 133 | }); 134 | }); 135 | ``` 136 | 137 | 실패하는 테스트 케이스가 잘 만들어졌나요? 그럼 구현을 시작해봅시다. 138 | 139 | #### `stats.js` 140 | 141 | ```javascript 142 | exports.max = numbers => Math.max(...numbers); 143 | exports.min = numbers => Math.min(...numbers); 144 | exports.avg = numbers => { 145 | const sum = numbers.reduce((acc, current) => acc + current, 0); 146 | return sum / numbers.length; 147 | }; 148 | ``` 149 | 150 | 테스트 케이스가 통과했지요? 여기서 조금 더 리팩토링을 하자면.. 맨 마지막 `sum / numbers.length` 부분을 굳이 저렇게 바깥에 넣지 않고 reduce 함수 내부에서 처리하게 할 수도 있습니다. 한번 해볼까요? 151 | 152 | #### `stats.js` 153 | 154 | ```javascript 155 | exports.max = numbers => Math.max(...numbers); 156 | exports.min = numbers => Math.min(...numbers); 157 | exports.avg = numbers => 158 | numbers.reduce( 159 | (acc, current, index, array) => 160 | index === array.length - 1 161 | ? (acc + current) / array.length 162 | : acc + current, 163 | 0 164 | ); 165 | ``` 166 | 167 | 음.. 해보니까 통과는 하는데 코드의 가독성이 오히려 안좋아졌습니다. 또 다시 바꿔볼까요? 168 | 169 | #### `stats.js` 170 | 171 | ```javascript 172 | exports.max = numbers => Math.max(...numbers); 173 | exports.min = numbers => Math.min(...numbers); 174 | exports.avg = numbers => 175 | numbers.reduce( 176 | (acc, current, index, array) => acc + current / array.length, 177 | 0 178 | ); 179 | ``` 180 | 181 | 방금 전 보다는 괜찮아졌습니다. 여기서 또! 리팩토링 할 수 있는게 있습니다. 182 | 183 | #### `stats.js` 184 | 185 | ```javascript 186 | exports.max = numbers => Math.max(...numbers); 187 | exports.min = numbers => Math.min(...numbers); 188 | exports.avg = numbers => 189 | numbers.reduce( 190 | (acc, current, index, { length }) => acc + current / length, 191 | 0 192 | ); 193 | ``` 194 | 195 | 배열의 `length` 를 구조 분해 문법을 사용하여 따로 추출해주었습니다. 196 | 197 | ### 중앙값 구하기 198 | 199 | 이번에는 중앙값을 구하는 기능을 구현해봅시다. 중앙값을 구현하기 전에 우선 배열을 정렬해야합니다. 정렬하는 함수를 먼저 구현해볼건데요, 이를 위한 테스트 케이스를 작성해보세요. 200 | 201 | #### `stats.test.js` 202 | 203 | ```javascript 204 | const stats = require('./stats'); 205 | 206 | describe('stats', () => { 207 | it('gets maximum value', () => { 208 | expect(stats.max([1, 2, 3, 4])).toBe(4); 209 | }); 210 | it('gets minimum value', () => { 211 | expect(stats.min([1, 2, 3, 4])).toBe(1); 212 | }); 213 | it('gets average value', () => { 214 | expect(stats.avg([1, 2, 3, 4, 5])).toBe(3); 215 | }); 216 | describe('median', () => { 217 | it('sorts the array', () => { 218 | expect(stats.sort([5, 4, 1, 2, 3])).toEqual([1, 2, 3, 4, 5]); 219 | }); 220 | }); 221 | }); 222 | ``` 223 | 224 | `describe` 내부에서 또 `describe` 를 쓸 수 있습니다. 단, `it` 내부에 또다른 `it` 이나 `describe` 를 쓸 수는 없습니다. 225 | 226 | 위 테스트 케이스에서는 우리가 `toBe` 가 아닌 `toEqual` 을 사용했는데요 이는 객체 또는 배열을 비교해야하는 상황에서 사용합니다. 227 | 228 | 이제 sort 를 구현해줍시다. 229 | 230 | #### `stats.js` 231 | 232 | ```javascript 233 | exports.max = numbers => Math.max(...numbers); 234 | exports.min = numbers => Math.min(...numbers); 235 | exports.avg = numbers => 236 | numbers.reduce( 237 | (acc, current, index, { length }) => acc + current / length, 238 | 0 239 | ); 240 | 241 | exports.sort = numbers => numbers.sort((a, b) => a - b); 242 | ``` 243 | 244 | 이제, 중앙값을 구현해줄건데요, 중앙값은 자료의 개수가 홀수개일때랑 짝수개일때랑 알아내는 방법이 다릅니다. 245 | 246 | `[1,2,3,4,5]` 처럼 숫자가 5개면, 중앙값은 3이 됩니다. 247 | `[1,2,3,4,5,6]` 처럼 숫자가 6개면, 중앙에 있는 값 3 + 4 / 2 의 결과인 3.5가 중앙값이 됩니다. 248 | 249 | 그럼 위 요구사항에 맞춰 테스트 케이스를 작성해볼까요? 250 | 251 | #### `stats.test.js` 252 | 253 | ```javascript 254 | const stats = require('./stats'); 255 | 256 | describe('stats', () => { 257 | it('gets maximum value', () => { 258 | expect(stats.max([1, 2, 3, 4])).toBe(4); 259 | }); 260 | it('gets minimum value', () => { 261 | expect(stats.min([1, 2, 3, 4])).toBe(1); 262 | }); 263 | it('gets average value', () => { 264 | expect(stats.avg([1, 2, 3, 4, 5])).toBe(3); 265 | }); 266 | describe('median', () => { 267 | it('sorts the array', () => { 268 | expect(stats.sort([5, 4, 1, 2, 3])).toEqual([1, 2, 3, 4, 5]); 269 | }); 270 | it('gets the median for odd length', () => { 271 | expect(stats.median([1, 2, 3, 4, 5])).toBe(3); 272 | }); 273 | it('gets the median for even length', () => { 274 | expect(stats.median([1, 2, 3, 4, 5, 6])).toBe(3.5); 275 | }); 276 | }); 277 | }); 278 | ``` 279 | 280 | 테스트 케이스들을 만들었으면, 구현을 해봅시다. 281 | 282 | #### `stats.js` 283 | 284 | ```javascript 285 | exports.max = numbers => Math.max(...numbers); 286 | exports.min = numbers => Math.min(...numbers); 287 | exports.avg = numbers => 288 | numbers.reduce( 289 | (acc, current, index, { length }) => acc + current / length, 290 | 0 291 | ); 292 | 293 | exports.sort = numbers => numbers.sort((a, b) => a - b); 294 | exports.median = numbers => { 295 | const middle = Math.floor(numbers.length / 2); 296 | 297 | if (numbers.length % 2) { 298 | // 홀수 299 | return numbers[middle]; 300 | } 301 | return (numbers[middle - 1] + numbers[middle]) / 2; 302 | }; 303 | ``` 304 | 305 | 리팩토링을 조금 해볼까요? 306 | 307 | #### `stats.js` 308 | 309 | ```javascript 310 | exports.max = numbers => Math.max(...numbers); 311 | exports.min = numbers => Math.min(...numbers); 312 | exports.avg = numbers => 313 | numbers.reduce( 314 | (acc, current, index, { length }) => acc + current / length, 315 | 0 316 | ); 317 | 318 | exports.sort = numbers => numbers.sort((a, b) => a - b); 319 | exports.median = numbers => { 320 | const { length } = numbers; 321 | const middle = Math.floor(length / 2); 322 | return length % 2 323 | ? numbers[middle] 324 | : (numbers[middle - 1] + numbers[middle]) / 2; 325 | }; 326 | ``` 327 | 328 | 테스트 케이스가 여전히 잘 통과하고 있나요? 329 | 330 | ### 최빈값 구하기 331 | 332 | 최빈값은 배열에서 가장 빈도가 높은 값 입니다. 이 값은 배열 안에 어떤 숫자들이 있느냐에 따라 형태가 다릅니다. 333 | 334 | 1. 주어진 값들 중에서 가장 자주 나타난 값이 결과가 됩니다. 335 | - `[1,2,2,2,3]` → `2` 336 | 2. 모든 값들의 빈도가 똑같다면 최빈값은 없습니다. 337 | - `[1,2,3]`, `[1,1,2,2,3,3]` → `null` 338 | 3. 빈도가 똑같은 값이 여러개라면, 결과값도 여러개입니다. 339 | - `[1,2,2,3,3,4]` → `[2,3]` 340 | 341 | 그럼, 위 요구사항에 맞춰서 테스트 케이스들을 만들어볼까요? 342 | 343 | #### `stats.test.js` 344 | 345 | ```javascript 346 | const stats = require('./stats'); 347 | 348 | describe('stats', () => { 349 | it('gets maximum value', () => { 350 | expect(stats.max([1, 2, 3, 4])).toBe(4); 351 | }); 352 | it('gets minimum value', () => { 353 | expect(stats.min([1, 2, 3, 4])).toBe(1); 354 | }); 355 | it('gets average value', () => { 356 | expect(stats.avg([1, 2, 3, 4, 5])).toBe(3); 357 | }); 358 | describe('median', () => { 359 | it('sorts the array', () => { 360 | expect(stats.sort([5, 4, 1, 2, 3])).toEqual([1, 2, 3, 4, 5]); 361 | }); 362 | it('gets the median for odd length', () => { 363 | expect(stats.median([1, 2, 3, 4, 5])).toBe(3); 364 | }); 365 | it('gets the median for even length', () => { 366 | expect(stats.median([1, 2, 3, 4, 5, 6])).toBe(3.5); 367 | }); 368 | }); 369 | describe('mode', () => { 370 | it('has one mode', () => { 371 | expect(stats.mode([1, 2, 2, 2, 3])).toBe(2); 372 | }); 373 | it('has no mode', () => { 374 | expect(stats.mode([1, 2, 3])).toBe(null); 375 | }); 376 | it('has multiple mode', () => { 377 | expect(stats.mode([1, 2, 2, 3, 3, 4])).toEqual([2, 3]); 378 | }); 379 | }); 380 | }); 381 | ``` 382 | 383 | 그럼, 요구사항에 맞춰서 하나하나 구현해봅시다! 이번에 충족해야하는 조건들이 꽤 많은데, 순서대로 하나씩 처리해봅시다. 384 | 385 | #### `stats.js` 386 | 387 | ```javascript 388 | exports.max = numbers => Math.max(...numbers); 389 | exports.min = numbers => Math.min(...numbers); 390 | exports.avg = numbers => 391 | numbers.reduce( 392 | (acc, current, index, { length }) => acc + current / length, 393 | 0 394 | ); 395 | 396 | exports.sort = numbers => numbers.sort((a, b) => a - b); 397 | exports.median = numbers => { 398 | const { length } = numbers; 399 | const middle = Math.floor(length / 2); 400 | return length % 2 401 | ? numbers[middle] 402 | : (numbers[middle - 1] + numbers[middle]) / 2; 403 | }; 404 | exports.mode = numbers => { 405 | const counts = new Map(); 406 | numbers.forEach(n => { 407 | const count = counts.get(n) || 0; 408 | counts.set(n, count + 1); 409 | }); 410 | const maxCount = Math.max(...counts.values()); 411 | const result = [...counts.keys()].find( 412 | number => counts.get(number) === maxCount 413 | ); 414 | return result; 415 | }; 416 | ``` 417 | 418 | 여기까지 구현하면, `mode` 함수의 첫번째 테스트 케이스가 만족된것을 볼 수 있습니다. 419 | 420 | ![](https://i.imgur.com/6pw1TJE.png) 421 | 422 | 나머지 테스트 케이스들도 충족시켜봅시다. 423 | 424 | #### `stats.js` 425 | 426 | ```javascript 427 | exports.max = numbers => Math.max(...numbers); 428 | exports.min = numbers => Math.min(...numbers); 429 | exports.avg = numbers => 430 | numbers.reduce( 431 | (acc, current, index, { length }) => acc + current / length, 432 | 0 433 | ); 434 | 435 | exports.sort = numbers => numbers.sort((a, b) => a - b); 436 | exports.median = numbers => { 437 | const { length } = numbers; 438 | const middle = Math.floor(length / 2); 439 | return length % 2 440 | ? numbers[middle] 441 | : (numbers[middle - 1] + numbers[middle]) / 2; 442 | }; 443 | 444 | exports.mode = numbers => { 445 | const counts = new Map(); 446 | numbers.forEach(n => { 447 | const count = counts.get(n) || 0; 448 | counts.set(n, count + 1); 449 | }); 450 | const maxCount = Math.max(...counts.values()); 451 | const modes = [...counts.keys()].filter( 452 | number => counts.get(number) === maxCount 453 | ); 454 | 455 | if (modes.length === numbers.length) { 456 | // 최빈값이 없음 457 | return null; 458 | } 459 | 460 | if (modes.length > 1) { 461 | // 최빈값이 여러개 462 | return modes; 463 | } 464 | 465 | // 최빈값이 하나 466 | return modes[0]; 467 | }; 468 | ``` 469 | 470 | 구현을 하고나면, 모든 테스트 케이스가 통과 할 것입니다! 471 | 472 | ![](https://i.imgur.com/d8AsDXg.png) 473 | 474 | 이제 리팩토링을 좀 시도해볼까요? 475 | 476 | #### `stats.js` 477 | 478 | ```javascript 479 | exports.max = numbers => Math.max(...numbers); 480 | exports.min = numbers => Math.min(...numbers); 481 | exports.avg = numbers => 482 | numbers.reduce( 483 | (acc, current, index, { length }) => acc + current / length, 484 | 0 485 | ); 486 | 487 | exports.sort = numbers => numbers.sort((a, b) => a - b); 488 | exports.median = numbers => { 489 | const { length } = numbers; 490 | const middle = Math.floor(length / 2); 491 | return length % 2 492 | ? numbers[middle] 493 | : (numbers[middle - 1] + numbers[middle]) / 2; 494 | }; 495 | 496 | exports.mode = numbers => { 497 | const counts = numbers.reduce( 498 | (acc, current) => acc.set(current, acc.get(current) + 1 || 1), 499 | new Map() 500 | ); 501 | 502 | const maxCount = Math.max(...counts.values()); 503 | const modes = [...counts.keys()].filter( 504 | number => counts.get(number) === maxCount 505 | ); 506 | 507 | if (modes.length === numbers.length) { 508 | // 최빈값이 없음 509 | return null; 510 | } 511 | 512 | if (modes.length > 1) { 513 | // 최빈값이 여러개 514 | return modes; 515 | } 516 | 517 | // 최빈값이 하나 518 | return modes[0]; 519 | }; 520 | ``` 521 | 522 | 기존에 `forEach` 로 처리하던 부분을 `reduce` 를 사용하여 구현해주었습니다. 523 | 524 | ## 정리 525 | 526 | 이번 실습을 통하여 여러분은 TDD 를 맛보기식으로 체험해보았습니다. 테스트 코드가 존재하니까 리팩토링을 하기가 훨씬 편하다는 것을 경험하셨나요? 추가적으로, 요구사항을 제대로 만족시키는것을 시각적으로 확인 할 수도 있고, 개발을 하게 될 때 각 테스트 케이스에 맞춰서 할 수 있다는 점에서 우리가 해결하고자 하는 문제에 조금 더 집중을 할 수도 있었습니다. 527 | -------------------------------------------------------------------------------- /docs/03-react-test.md: -------------------------------------------------------------------------------- 1 | # 3. 리액트 컴포넌트의 테스트 2 | 3 | 리액트 컴포넌트를 테스팅 할 때에는 [`react-dom/test-utils`](https://reactjs.org/docs/test-utils.html) 안에 들어있는 유틸 함수를 사용해서 테스트 코드를 작성합니다. 4 | 5 | 그런데, 위 유틸 함수들을 직접 사용해서 테스트 코드를 작성하는건 불가능한건 아니지만 조금 복잡하고, 불편한점들이 있기 때문에, 테스팅 라이브러리를 사용 하는것을 [리액트 공식문서](https://reactjs.org/docs/test-utils.html#overview)에서도 권장하고 있습니다. 6 | 7 | ### Enzyme 과 react-testing-library 8 | 9 | 리액트 공식문서에서 사용을 권장하는 라이브러리는 [`react-testing-library`](https://git.io/react-testing-library) 입니다. 그리고, 대체방안으로 [`Enzyme`](https://airbnb.io/enzyme/) 이 있다고 언급을 하고 있습니다. 10 | 11 | 2년 전까지는 airbnb 에서 만든 Enzyme 을 사용하는것이 가장 좋은 솔루션이였는데요, 요즘은 react-testing-library 가 많은 주목을 받고 있습니다. Enzyme 의 경우엔 2015년부터 개발이 되었고 react-testing-library 의 경우엔 2018년부터 개발이 되어 2018년 말부터 급부상을 하고 있습니다. 12 | 13 | ![](https://i.imgur.com/pAV5xFl.png) 14 | 15 | 사용률만 따진다면 Enzyme 이 사용률이 훨씬 높긴합니다. 16 | 17 | ![](https://i.imgur.com/qJLVvjk.png) 18 | 19 | Enzyme 과 react-testing-library 는 서로 다른 철학을 가지고 있습니다. Enzyme 을 사용하여 테스트 코드를 작성 할 때에는 컴포넌트의 내부 기능을 자주 접근합니다. 예를 들어서 컴포넌트가 지니고 있는 props, state 를 확인하고, 컴포넌트의 내장 메서드를 직접 호출하기도 합니다. 20 | 21 | react-testing-library는 반면 렌더링 결과에 조금 더 집중을 합니다. 실제 DOM 에 대해서 신경을 더 많이 쓰고, 컴포넌트의 인스턴스에 대해서 신경쓰지 않고, 실제 화면에 무엇이 보여지는지, 그리고 어떠한 이벤트가 발생했을때 화면에 원하는 변화가 생겼는지 이런 것을 확인하기에 조금 더 최적화 되어있습니다. 그래서, react-testing-library 는 조금 더 사용자의 관점에서 테스팅하기에 더욱 용이합니다. 22 | 23 | 이 튜토리얼에서는, Enzyme 과 react-testing-library 둘 다 다뤄볼건데요, react-testing-library 에 대해서 더욱 심도깊게 다뤄볼것입니다. 24 | 25 | 그리고, 우리가 리액트 프로젝트에서 TDD 를 진행하기 전에, 리액트에서 테스트를 어떤 식으로 할 수 있는지 먼저 알아보고, TDD 흐름으로 개발을 하는 것은 나중에 해보겠습니다. 26 | -------------------------------------------------------------------------------- /docs/04-enzyme.md: -------------------------------------------------------------------------------- 1 | # 4. Enzyme 사용법 2 | 3 | ## 리액트 프로젝트 만들기 4 | 5 | 우선, 우리가 테스팅을 연습할 리액트 프로젝트를 만들겠습니다. CRA 를 통하여 프로젝트를 생성해주세요. 6 | 7 | ``` 8 | $ yarn create react-app react-enzyme-test 9 | # 혹은 npx create-react-app react-enzyme-test 10 | ``` 11 | 12 | CRA 로 만든 프로젝트에는 Jest 가 처음부터 적용되어있기 때문에 별도로 jest 설치를 하지 않으셔도 됩니다. VS Code 를 사용하시는 경우 IDE 지원을 제대로 받기 위하여 `@types/jest` 만 설치해주세요. 13 | 14 | ## 설치 15 | 16 | 리액트 프로젝트를 열어서 다음 라이브러리들을 설치하세요. 17 | 18 | ```bash 19 | $ yarn add enzyme enzyme-adapter-react-16 20 | # 또는 npm install --save enzyme enzyme-adapter-react-16 21 | ``` 22 | 23 | 그 다음, src 디렉터리에 setupTests.js 라는 파일을 만들어서 다음 코드를 입력하세요. 24 | 25 | #### `src/setupTests.js` 26 | 27 | ```jsx 28 | import { configure } from 'enzyme'; 29 | import Adapter from 'enzyme-adapter-react-16'; 30 | 31 | configure({ adapter: new Adapter() }); 32 | ``` 33 | 34 | 그 다음, Profile 이라는 컴포넌트를 만들어보겠습니다. 이 컴포넌트에서는, username 과 name 값을 가져와서 화면상에 보여줍니다. 35 | 36 | #### `src/Profile.js` 37 | 38 | ```jsx 39 | import React from 'react'; 40 | 41 | const Profile = ({ username, name }) => { 42 | return ( 43 |
44 | {username}  45 | ({name}) 46 |
47 | ); 48 | }; 49 | 50 | export default Profile; 51 | ``` 52 | 53 | 이 컴포넌트를 App 컴포넌트에서 렌더링하고 `yarn start` (혹은 `npm start`) 를 입력하여 결과를 확인해보세요. 54 | 55 | ```jsx 56 | import React from 'react'; 57 | import Profile from './Profile'; 58 | 59 | function App() { 60 | return ( 61 |
62 | 63 |
64 | ); 65 | } 66 | 67 | export default App; 68 | ``` 69 | 70 | !> _Cannot find module '@babel/plugin-transform-react-jsx-source'_ 라는 에러가 발생하면 node_modules 를 제거한 후, yarn install (혹은 npm install) 명령어를 입력하여 패키지들을 재설치해보세요. 71 | 72 | ![](https://i.imgur.com/yHXxZce.png) 73 | 74 | 위 결과물이 잘 나타났나요? 이제 이 컴포넌트를 위한 테스트 코드를 작성해보겠습니다. 이 컴포넌트의 테스트 코드에서는 props 로 값을 넣어줬을 때 username 과 name 값이 잘 나타났는지 확인해주어야 합니다. 75 | 76 | ## 스냅샷 테스팅 77 | 78 | 스냅샷 테스팅이란, 렌더링된 결과가 이전에 렌더링한 결과와 일치하는지 확인하는 작업을 의미합니다. Enzyme 에서 스냅샷 테스팅을 하려면 `enzyme-to-json` 이라는 라이브러리를 설치해주어야 합니다. 79 | 80 | ```bash 81 | $ yarn add enzyme-to-json 82 | ``` 83 | 84 | 그 다음에는, package.json 파일을 열어서 다음과 같이 `"jest"` 설정을 넣어주세요. 85 | 86 | #### `package.json` 87 | 88 | ```json 89 | { 90 | "name": "react-enzyme-test", 91 | "version": "0.1.0", 92 | "private": true, 93 | "dependencies": { 94 | "@types/jest": "^24.0.13", 95 | "enzyme": "^3.9.0", 96 | "enzyme-adapter-react-16": "^1.13.1", 97 | "enzyme-to-json": "^3.3.5", 98 | "react": "^16.8.6", 99 | "react-dom": "^16.8.6", 100 | "react-scripts": "3.0.1" 101 | }, 102 | "scripts": { 103 | "start": "react-scripts start", 104 | "build": "react-scripts build", 105 | "test": "react-scripts test", 106 | "eject": "react-scripts eject" 107 | }, 108 | "eslintConfig": { 109 | "extends": "react-app" 110 | }, 111 | "browserslist": { 112 | "production": [">0.2%", "not dead", "not op_mini all"], 113 | "development": [ 114 | "last 1 chrome version", 115 | "last 1 firefox version", 116 | "last 1 safari version" 117 | ] 118 | }, 119 | "jest": { 120 | "snapshotSerializers": ["enzyme-to-json/serializer"] 121 | } 122 | } 123 | ``` 124 | 125 | 그 다음, Profile.test.js 파일을 다음과 같이 작성해보세요. 126 | 127 | #### `Profile.test.js` 128 | 129 | ```jsx 130 | import React from 'react'; 131 | import { mount } from 'enzyme'; 132 | import Profile from './Profile'; 133 | 134 | describe('', () => { 135 | it('matches snapshot', () => { 136 | const wrapper = mount(); 137 | expect(wrapper).toMatchSnapshot(); 138 | }); 139 | }); 140 | ``` 141 | 142 | `mount` 라는 함수는 Enzyme 을 통하여 리액트 컴포넌트를 렌더링 해줍니다. 이를 통해서 만든 wrapper 를 통해서 우리가 추후 props 조회, DOM 조회, state 조회 등을 할 수 있습니다. `mount` 외에도 `shallow` 라는 함수도 있는데요. 이에 대해선 나중에 알아보겠습니다. 143 | 144 | 그리고 나서, 다음 명령어를 입력하여 테스트 코드를 실행하세요. 145 | 146 | ```bash 147 | $ yarn test 148 | ``` 149 | 150 | ![](https://i.imgur.com/B6TSKOV.png) 151 | 152 | 위와 같이 _1 snapshot updated_ 라는 문구가 보여지고 src 디렉터리에 `\_\_snapshots\_\_/Profile.test.js.snap/` 라는 파일이 생겼을 것입니다. 153 | 154 | ```js 155 | // Jest Snapshot v1, https://goo.gl/fbAQLP 156 | 157 | exports[` matches snapshot 1`] = ` 158 | 162 |
163 | 164 | velopert 165 | 166 |   167 | 168 | ( 169 | 김민준 170 | ) 171 | 172 |
173 |
174 | `; 175 | ``` 176 | 177 | 만약에 컴포넌트를 수정하게 되면 이 스냅샷이 일치하지 않게 되면서 테스트가 실패할 것입니다. 예를 들어서 다음과 같이 username 뒤에 느낌표를 붙이면 178 | 179 | #### `Profile.js` 180 | 181 | ```jsx 182 | import React from 'react'; 183 | 184 | const Profile = ({ username, name }) => { 185 | return ( 186 |
187 | {username}!  188 | ({name}) 189 |
190 | ); 191 | }; 192 | 193 | export default Profile; 194 | ``` 195 | 196 | ![](https://i.imgur.com/e42tnV3.png) 197 | 198 | 이렇게 실패했다고 나타납니다. 만약 현재 결과물이 제대로 된거고, 스냅샷을 현재 결과물로 업데이트 하고 싶다면, 콘솔창에서 `u` 키를 누르면 됩니다. 한번 눌러보세요. 스냅샷이 업데이트 된것이 확인 됐다면, 느낌표를 지우고 또 다시 스냅샷을 원래 상태로 다시 업데이트하세요. 199 | 200 | ## props 접근 201 | 202 | Enzyme 에서는 컴포넌트 인스턴스에 접근을 할 수 있습니다. 한번 다음과 같이 새로운 테스트 케이스를 만들어보세요. 203 | 204 | #### `src/Profile.test.js` 205 | 206 | ```jsx 207 | import React from 'react'; 208 | import { mount } from 'enzyme'; 209 | import Profile from './Profile'; 210 | 211 | describe('', () => { 212 | it('matches snapshot', () => { 213 | const wrapper = mount(); 214 | expect(wrapper).toMatchSnapshot(); 215 | }); 216 | it('renders username and name', () => { 217 | const wrapper = mount(); 218 | expect(wrapper.props().username).toBe('velopert'); 219 | expect(wrapper.props().name).toBe('김민준'); 220 | }); 221 | }); 222 | ``` 223 | 224 | 이렇게 콘솔에 출력을 하게 하면 테스트 하는 콘솔에서 결과가 나타납니다. 한번 콘솔을 확인해보세요. 225 | 226 | ![](https://i.imgur.com/4g5KSPz.png) 227 | 228 | ## DOM 확인 229 | 230 | DOM 에 우리가 원하는 텍스트가 나타나있는지 확인을 해보겠습니다. 231 | 232 | #### `src/Profile.test.js` 233 | 234 | ```jsx 235 | import React from 'react'; 236 | import { mount } from 'enzyme'; 237 | import Profile from './Profile'; 238 | 239 | describe('', () => { 240 | it('matches snapshot', () => { 241 | const wrapper = mount(); 242 | expect(wrapper).toMatchSnapshot(); 243 | }); 244 | it('renders username and name', () => { 245 | const wrapper = mount(); 246 | 247 | expect(wrapper.props().username).toBe('velopert'); 248 | expect(wrapper.props().name).toBe('김민준'); 249 | 250 | const boldElement = wrapper.find('b'); 251 | expect(boldElement.contains('velopert')).toBe(true); 252 | const spanElement = wrapper.find('span'); 253 | expect(spanElement.text()).toBe('(김민준)'); 254 | }); 255 | }); 256 | ``` 257 | 258 | `find` 함수를 사용하면 특정 DOM 을 선택 할 수 있습니다. 여기에 입력하는 값은 브라우저의 `querySelector` 와 같습니다. CSS 클래스는 `find('.my-class')`, id 는 `find('#myid')`, 태그는 `find('span')` 이런식으로 조회를 할 수 있으며, 여기에 컴포넌트의 Display Name 을 사용하면 특정 컴포넌트의 인스턴스도 찾을 수 있습니다 (예: `find('MyComponent')`) 259 | 260 | ## 클래스형 컴포넌트의 테스팅 261 | 262 | 이번에는 클래스형 컴포넌트의 내부메서드 호출 및 state 를 조회하는 방법을 알아보겠습니다. 깨어있는 (?) 리액트 개발자라면 Hooks 를 사용하고 싶겠지만 이건 다음 섹션에서 진행하겠습니다. 263 | 264 | Counter 컴포넌트를 만들어봅시다. 265 | 266 | #### `src/Counter.js` 267 | 268 | ```jsx 269 | import React, { Component } from 'react'; 270 | 271 | class Counter extends Component { 272 | state = { 273 | number: 0 274 | }; 275 | handleIncrease = () => { 276 | this.setState({ 277 | number: this.state.number + 1 278 | }); 279 | }; 280 | handleDecrease = () => { 281 | this.setState({ 282 | number: this.state.number - 1 283 | }); 284 | }; 285 | render() { 286 | return ( 287 |
288 |

{this.state.number}

289 | 290 | 291 |
292 | ); 293 | } 294 | } 295 | 296 | export default Counter; 297 | ``` 298 | 299 | 이제 Counter 컴포넌트를 어떻게 테스트 할 수 있는지 알아볼까요? 300 | 301 | #### `src/Counter.test.js` 302 | 303 | ```jsx 304 | import React from 'react'; 305 | import { shallow } from 'enzyme'; 306 | import Counter from './Counter'; 307 | 308 | describe('', () => { 309 | it('matches snapshot', () => { 310 | const wrapper = shallow(); 311 | expect(wrapper).toMatchSnapshot(); 312 | }); 313 | it('has initial number', () => { 314 | const wrapper = shallow(); 315 | expect(wrapper.state().number).toBe(0); 316 | }); 317 | it('increases', () => { 318 | const wrapper = shallow(); 319 | wrapper.instance().handleIncrease(); 320 | expect(wrapper.state().number).toBe(1); 321 | }); 322 | it('decreases', () => { 323 | const wrapper = shallow(); 324 | wrapper.instance().handleDecrease(); 325 | expect(wrapper.state().number).toBe(-1); 326 | }); 327 | }); 328 | ``` 329 | 330 | 여기서는 우리가 `mount` 대신에 `shallow` 라는 함수를 사용해주었는데요, `shallow` 는 컴포넌트 내부에 또다른 리액트 컴포넌트가 있다면 이를 렌더링하지 않습니다. 만약에 우리가 Profile 컴포넌트를 Counter 컴포넌트에서 렌더링 할 경우에는 `shallow` 의 경우 다음과 같은 결과가 나타나고, 331 | 332 | ``` 333 | // Jest Snapshot v1, https://goo.gl/fbAQLP 334 | 335 | exports[` matches snapshot 1`] = ` 336 |
337 |

338 | 0 339 |

340 | 345 | 350 | 354 |
355 | `; 356 | ``` 357 | 358 | `mount` 의 경우 다음과 같은 결과가 나타납니다. 359 | 360 | ``` 361 | // Jest Snapshot v1, https://goo.gl/fbAQLP 362 | 363 | exports[` matches snapshot 1`] = ` 364 | 365 |
366 |

367 | 0 368 |

369 | 374 | 379 | 383 |
384 | 385 | velopert 386 | 387 |   388 | 389 | ( 390 | 김민준 391 | ) 392 | 393 |
394 |
395 |
396 |
397 | `; 398 | ``` 399 | 400 | 보시면, `mount` 의 경우 Profile 내부의 내용까지 전부 렌더링 된 반면, `shallow` 에선 이 작업이 생략됐지요? 추가적으로, `mount` 에서는 최상위 요소가 Counter 컴포넌트인 반면에, `shallow` 에서는 최상위 요소가 div 입니다. 따라서, `shallow` 를 할 경우 `wrapper.props()` 를 조회하게 되면 컴포넌트의 props 가 나타나는 것이 아니라 div 의 props 가 나타나게 됩니다. 401 | 402 | ```js 403 | expect(wrapper.state().number).toBe(0); 404 | ``` 405 | 406 | 컴포넌트의 state 를 조회 할 때에는 위와 같이 `state()` 함수를 사용합니다. 407 | 408 | ```js 409 | wrapper.instance().handleIncrease(); 410 | ``` 411 | 412 | 그리고, 내장 메서드를 호출할때에는 `instance()` 함수를 호출하여 인스턴스를 조회 후 메서드를 호출 할 수 있습니다. 413 | 414 | ## DOM 이벤트 시뮬레이트 415 | 416 | 이번에는 내장 메서드를 직접 호출하는게 아니라, 버튼 클릭 이벤트를 시뮬레이트하여 기능이 잘 작동하는지 확인해보겠습니다. 417 | 418 | #### `Counter.test.js` 419 | 420 | ```jsx 421 | import React from 'react'; 422 | import { shallow } from 'enzyme'; 423 | import Counter from './Counter'; 424 | 425 | describe('', () => { 426 | it('matches snapshot', () => { 427 | const wrapper = shallow(); 428 | expect(wrapper).toMatchSnapshot(); 429 | }); 430 | it('has initial number', () => { 431 | const wrapper = shallow(); 432 | expect(wrapper.state().number).toBe(0); 433 | }); 434 | it('increases', () => { 435 | const wrapper = shallow(); 436 | wrapper.instance().handleIncrease(); 437 | expect(wrapper.state().number).toBe(1); 438 | }); 439 | it('decreases', () => { 440 | const wrapper = shallow(); 441 | wrapper.instance().handleDecrease(); 442 | expect(wrapper.state().number).toBe(-1); 443 | }); 444 | it('calls handleIncrease', () => { 445 | // 클릭이벤트를 시뮬레이트하고, state 를 확인 446 | const wrapper = shallow(); 447 | const plusButton = wrapper.findWhere( 448 | node => node.type() === 'button' && node.text() === '+1' 449 | ); 450 | plusButton.simulate('click'); 451 | expect(wrapper.state().number).toBe(1); 452 | }); 453 | it('calls handleDecrease', () => { 454 | // 클릭 이벤트를 시뮬레이트하고, h2 태그의 텍스트 확인 455 | const wrapper = shallow(); 456 | const minusButton = wrapper.findWhere( 457 | node => node.type() === 'button' && node.text() === '-1' 458 | ); 459 | minusButton.simulate('click'); 460 | const number = wrapper.find('h2'); 461 | expect(number.text()).toBe('-1'); 462 | }); 463 | }); 464 | ``` 465 | 466 | 위 테스트 케이스에서는 [`findWhere()`](https://airbnb.io/enzyme/docs/api/ShallowWrapper/findWhere.html) 함수를 사용하여 우리가 원하는 버튼 태그를 선택해주었습니다. 이 함수를 사용하면 우리가 원하는 조건을 만족하는 태그를 선택 할 수 있습니다. 467 | 468 | 만약에 `findWhere()` 를 사용하지 않는다면 다음과 같이 코드를 작성해야합니다. 469 | 470 | ```js 471 | const buttons = wrapper.find('button'); 472 | const plusButton = buttons.get(0); // 첫번째 버튼 +1 473 | const minusButton = buttons.get(1); // 두번째 버튼 -1 474 | ``` 475 | 476 | 버튼에 이벤트를 시뮬레이트 할 때에는 원하는 엘리먼트를 찾아서 `simulate()` 함수를 사용합니다. 첫번째 파라미터에는 이벤트 이름을 넣고 두번째 파라미터에는 이벤트 객체를 넣습니다. 만약에 인풋에 change 이벤트를 발생시키는 경우엔 다음과 같이 하면 됩니다. 477 | 478 | ```javascript 479 | input.simulate('change', { 480 | target: { 481 | value: 'hello world' 482 | } 483 | }); 484 | ``` 485 | 486 | 그리고, 값이 잘 업데이트 됐는지 확인하기 위해서 두가지 방법을 사용했는데요, 첫번째 방법은 state 를 직접 조회하는 것이고, 두번째 방법은 h2 태그를 조회해서 값을 확인하는 것 입니다. 실제 테스트 코드를 작성하게 될 때에는 이 방법 중 아무거나 선택하셔도 상관없습니다. 487 | 488 | ## 함수형 컴포넌트와 Hooks 테스팅 489 | 490 | 이번에는 Hooks 를 사용하는 함수형 컴포넌트의 테스트 코드를 작성해봅시다. HookCounter 라는 컴포넌트를 만들어보세요. 491 | 492 | #### `src/HookCounter.js` 493 | 494 | ```jsx 495 | import React, { useState, useCallback } from 'react'; 496 | 497 | const HookCounter = () => { 498 | const [number, setNumber] = useState(0); 499 | const onIncrease = useCallback(() => { 500 | setNumber(number + 1); 501 | }, [number]); 502 | const onDecrease = useCallback(() => { 503 | setNumber(number - 1); 504 | }, [number]); 505 | 506 | return ( 507 |
508 |

{number}

509 | 510 | 511 |
512 | ); 513 | }; 514 | 515 | export default HookCounter; 516 | ``` 517 | 518 | 컴포넌트를 만들고 App 에서 렌더링하여 잘 작동하는지 직접 먼저 확인해보세요. 519 | 520 | #### `src/App.js` 521 | 522 | ```jsx 523 | import React from 'react'; 524 | 525 | import HookCounter from './HookCounter'; 526 | 527 | function App() { 528 | return ( 529 |
530 | 531 |
532 | ); 533 | } 534 | 535 | export default App; 536 | ``` 537 | 538 | 이 컴포넌트를 위한 테스트케이스를 작성해보겠습니다. 함수형 컴포넌트에서는 클래스형 컴포넌트와 달리 인스턴스 메서드 및 상태를 조회 할 방법이 없습니다. 추가적으로, Hooks 를 사용하는 경우 꼭 `shallow` 가 아닌 `mount` 를 사용하셔야 합니다. 그 이유는, `useEffect` Hook 은 `shallow` 에서 작동하지 않고, 버튼 엘리먼트에 연결되어있는 함수가 이전 함수를 가르키고 있기 때문에, 예를 들어 +1 버튼의 클릭 이벤트를 두번 시뮬레이트해도 결과값이 2가 되는게 아니라 1이 됩니다. 539 | 540 | #### `HookCounter.test.js` 541 | 542 | ```javascript 543 | import React from 'react'; 544 | import { mount } from 'enzyme'; 545 | import HookCounter from './HookCounter'; 546 | 547 | describe('', () => { 548 | it('matches snapshot', () => { 549 | const wrapper = mount(); 550 | expect(wrapper).toMatchSnapshot(); 551 | }); 552 | it('increases', () => { 553 | const wrapper = mount(); 554 | let plusButton = wrapper.findWhere( 555 | node => node.type() === 'button' && node.text() === '+1' 556 | ); 557 | plusButton.simulate('click'); 558 | plusButton.simulate('click'); 559 | 560 | const number = wrapper.find('h2'); 561 | 562 | expect(number.text()).toBe('2'); 563 | }); 564 | it('decreases', () => { 565 | const wrapper = mount(); 566 | let decreaseButton = wrapper.findWhere( 567 | node => node.type() === 'button' && node.text() === '-1' 568 | ); 569 | decreaseButton.simulate('click'); 570 | decreaseButton.simulate('click'); 571 | 572 | const number = wrapper.find('h2'); 573 | 574 | expect(number.text()).toBe('-2'); 575 | }); 576 | }); 577 | ``` 578 | 579 | ## 정리 580 | 581 | 이번 섹션에서는 Enzyme 을 통한 컴포넌트 테스팅에 대해서 알아보았습니다. Enzyme 의 [공식 문서](https://airbnb.io/enzyme/docs/api/)를 보면, Enzyme 에 있는 더 많은 기능들을 볼 수 있습니다. 582 | -------------------------------------------------------------------------------- /docs/05-react-testing-library.md: -------------------------------------------------------------------------------- 1 | # 5. react-testing-library 사용법 2 | 3 | [react-testing-library](https://testing-library.com/docs/react-testing-library/intro) 에서는 Enzyme 과 달리 모든 테스트를 DOM 위주로 진행합니다. 그리고, 컴포넌트의 props 나 state 를 조회하는 일은 없습니다. 컴포넌트를 리팩토링하게 될 때에는, 주로 내부 구조 및 네이밍은 많이 바뀔 수 있어도 실제 작동 방식은 크게 바뀌지 않습니다. react-testing-library는 이 점을 중요시 여겨서, 컴포넌트의 기능이 똑같이 작동한다면 컴포넌트의 내부 구현 방식이 많이 바뀌어도 테스트가 실패하지 않도록 설계되었습니다. 추가적으로, Enzyme 은 엄청나게 다양한 기능을 제공하는 반면, react-testing-library 에는 정말 필요한 기능들만 지원을 해줘서 매우 가볍고, 개발자들이 일관성 있고 좋은 관습을 따르는 테스트 코드를 작성 할 수 있도록 유도해줍니다. 4 | 5 | ## 리액트 프로젝트 만들기 6 | 7 | > 이번에 만들 컴포넌트들은 Enzyme 편에서 만든 컴포넌트들과 똑같습니다. 단, Enzyme 부분을 생략하고 바로 여기로 넘어오시는 분들을 위하여 프로젝트를 새로구성하겠습니다. 8 | 9 | CRA 를 통하여 새 프로젝트를 만들어주세요. 10 | 11 | ```bash 12 | $ yarn create rtl-tutorial 13 | # 또는 npx create-react-app rtl-tutorial 14 | ``` 15 | 16 | ## 설치 17 | 18 | react-testing-library 를 프로젝트에 설치해봅시다. 19 | 20 | ```bash 21 | $ yarn add react-testing-library jest-dom 22 | # 또는 npm install --save react-testing-library jest-dom 23 | ``` 24 | 25 | > jest-dom 은 jest 확장으로서, DOM 에 관련된 `matcher` 를 추가해줍니다. 26 | 27 | > VS Code 를 사용하는 경우 @types/jest 패키지도 설치하세요. 28 | 29 | 그 다음, src 디렉터리에 setupTests.js 파일을 생성해서 다음 코드를 입력해주세요. 30 | 31 | #### `src/setupTests.js` 32 | 33 | ```javascript 34 | import 'react-testing-library/cleanup-after-each'; 35 | import 'jest-dom/extend-expect'; 36 | ``` 37 | 38 | react-testing-library 에서는 리액트에서는 DOM 시뮬레이션을 위한 `[JSDOM](https://github.com/jsdom/jsdom)` 이라는 도구를 사용하여 `document.body` 에 리액트 컴포넌트를 렌더링합니다. `clean-up-after-each` 를 불러오면, 각 테스트 케이스가 끝날때마다 기존에 가상의 화면에 남아있는 UI 를 정리합니다. 39 | 40 | 추가적으로, 그 아래에는 `jest-dom/extend-expect` 를 불러와서 jest 에서 DOM 관련 `matcher` 를 사용 할 수 있게 해주었습니다. 41 | 42 | ## 첫번째 테스트 코드 43 | 44 | username 과 name 을 props 로 넣어주면 이를 렌더링해주는 Profile 컴포넌트를 만들어봅시다. 45 | 46 | #### `src/Profile.js` 47 | 48 | ```jsx 49 | import React from 'react'; 50 | 51 | const Profile = ({ username, name }) => { 52 | return ( 53 |
54 | {username}  55 | ({name}) 56 |
57 | ); 58 | }; 59 | 60 | export default Profile; 61 | ``` 62 | 63 | 이 컴포넌트를 만드셨으면 App 에서 렌더링해본 뒤 잘 보여지는지 먼저 확인해보세요. 64 | 65 | #### `src/App.js` 66 | 67 | ```jsx 68 | import React from 'react'; 69 | import Profile from './Profile'; 70 | 71 | const App = () => { 72 | return ; 73 | }; 74 | 75 | export default App; 76 | ``` 77 | 78 | App 을 수정하셨으면, `yarn start` (혹은 `npm start`) 를 입력하여 결과를 확인해보세요. 79 | 80 | ![](https://i.imgur.com/yHXxZce.png) 81 | 82 | 그리고, Profile 컴포넌트를 위한 테스트 코드를 작성해봅시다. 83 | 84 | #### `src/Profile.test.js` 85 | 86 | ```jsx 87 | import React from 'react'; 88 | import { render } from 'react-testing-library'; 89 | import Profile from './Profile'; 90 | 91 | describe('', () => { 92 | it('matches snapshot', () => { 93 | const utils = render(); 94 | expect(utils.container).toMatchSnapshot(); 95 | }); 96 | it('shows the props correctly', () => { 97 | const utils = render(); 98 | utils.getByText('velopert'); // velopert 라는 텍스트를 가진 엘리먼트가 있는지 확인 99 | utils.getByText('(김민준)'); // (김민준) 이라는 텍스트를 가진 엘리먼트가 있는지 확인 100 | utils.getByText(/김/); // 정규식 /김/ 을 통과하는 엘리먼트가 있는지 확인 101 | }); 102 | }); 103 | ``` 104 | 105 | 이제 `yarn test` (혹은 `npm test`) 명령어를 실행해서 작성한 테스트가 잘 통과하는지 확인해보세요. 106 | 107 | react-testing-library 에서 컴포넌트를 렌더링 할 때에는 `render()` 라는 함수를 사용합니다. 이 함수가 호출되면 그 [결과물](https://testing-library.com/docs/react-testing-library/api#render-result) 에는 DOM 을 선택 할 수 있는 [다양한 쿼리](https://testing-library.com/docs/dom-testing-library/api-queries)들과 `container` 가 포함되어있는데요, 여기서 `container` 는 해당 컴포넌트의 최상위 `DOM` 을 가르킵니다. 이를 가지고 스냅샷 테스팅을 할 수도 있습니다. 108 | 109 | 그리고, 그 하단의 `getByText` 는 쿼리함수라고 부르는데요 이 함수를 사용하면 텍스트를 사용해서 원하는 DOM 을 선택 할 수 있습니다. 이에 대해서는 잠시 후 더 자세히 알아보겠습니다. 110 | 111 | ## 스냅샷 테스팅 112 | 113 | 스냅샷 테스팅이란, 렌더링된 결과가 이전에 렌더링한 결과와 일치하는지 확인하는 작업을 의미합니다. 114 | 115 | 코드를 저장하면 `src/__snapshots__/Profile.test.js.snap` 라는 파일이 다음과 같이 만들어질 것입니다. 116 | 117 | ``` 118 | // Jest Snapshot v1, https://goo.gl/fbAQLP 119 | 120 | exports[` matches snapshot 1`] = ` 121 |
122 |
123 | 124 | velopert 125 | 126 |   127 | 128 | ( 129 | 김민준 130 | ) 131 | 132 |
133 |
134 | `; 135 | ``` 136 | 137 | 컴포넌트가 렌더링됐을 때 이 스냅샷과 일치하지 않으면 테스트가 실패합니다. 만약에 스냅샷을 업데이트 하고싶다면 테스트가 실행되고 있는 콘솔 창에서 `u` 키를 누르면 됩니다. 138 | 139 | ## 다양한 쿼리 140 | 141 | `render` 함수를 실행하고 나면 그 결과물 안에는 [다양한 쿼리](https://testing-library.com/docs/dom-testing-library/api-queries) 함수들이 있는데요, 이 쿼리 함수들은 react-testing-library 의 기반인 [`dom-testing-library`](https://testing-library.com/docs/dom-testing-library/intro) 에서 지원하는 함수들입니다. 142 | 143 | 이 쿼리 함수들은 `Variant` 와 `Queries` 의 조합으로 네이밍이 이루어져있는데요, 우선 `Varient` 에는 어떤 종류들이 있는지 봅시다. 144 | 145 | ### Variant 146 | 147 | #### getBy 148 | 149 | `getBy*` 로 시작하는 쿼리는 조건에 일치하는 DOM 엘리먼트 하나를 선택합니다. 만약에 없으면 에러가 발생합니다. 150 | 151 | #### getAllBy 152 | 153 | `getAllBy*` 로 시작하는 쿼리는 조건에 일치하는 DOM 엘리먼트 여러개를 선택합니다. 만약에 하나도 없으면 에러가 발생합니다. 154 | 155 | #### queryBy 156 | 157 | `queryBy*` 로 시작하는 쿼리는 조건에 일치하는 DOM 엘리먼트 하나를 선택합니다. 만약에 존재하지 않아도 에러가 발생하지 않습니다. 158 | 159 | #### queryAllBy 160 | 161 | `queryAllBy*` 로 시작하는 쿼리는 조건에 일치하는 DOM 엘리먼트 여러개를 선택합니다. 만약에 존재하지 않아도 에러가 발생하지 않습니다. 162 | 163 | #### findBy 164 | 165 | `findBy*` 로 시작하는 쿼리는 조건에 일치하는 DOM 엘리먼트 하나가 나타날 때 까지 기다렸다가 해당 DOM 을 선택하는 Promise 를 반환합니다. 기본 timeout 인 4500ms 이후에도 나타나지 않으면 에러가 발생합니다. 166 | 167 | #### findAllBy 168 | 169 | `findBy*` 로 시작하는 쿼리는 조건에 일치하는 DOM 엘리먼트 여러개가 나타날 때 까지 기다렸다가 해당 DOM 을 선택하는 Promise 를 반환합니다. 기본 timeout 인 4500ms 이후에도 나타나지 않으면 에러가 발생합니다. 170 | 171 | ### Queries 172 | 173 | #### ByLabelText 174 | 175 | `ByLabelText` 는 label 이 있는 input 의 label 내용으로 input 을 선택합니다. 176 | 177 | ```jsx 178 | 179 | 180 | 181 | const inputNode = getByLabelText('아이디'); 182 | ``` 183 | 184 | #### ByPlaceholderText 185 | 186 | `ByPlaceholderText` 는 placeholder 값으로 input 및 textarea 를 선택합니다. 187 | 188 | ```jsx 189 | ; 190 | 191 | const inputNode = getByPlaceholderText('아이디'); 192 | ``` 193 | 194 | #### ByText 195 | 196 | `ByText`는 엘리먼트가 가지고 있는 텍스트 값으로 DOM 을 선택합니다. 197 | 198 | ```jsx 199 |
Hello World!
; 200 | 201 | const div = getByText('Hello World!'); 202 | ``` 203 | 204 | 참고로, 텍스트 값에 정규식을 넣어도 작동합니다. 205 | 206 | ```jsx 207 | const div = getByText(/^Hello/); 208 | ``` 209 | 210 | #### ByAltText 211 | 212 | `ByAltText` 는 `alt` 속성을 가지고 있는 엘리먼트 (주로 `img`) 를 선택합니다. 213 | 214 | ```jsx 215 | awesome image; 216 | 217 | const imgAwesome = getByAltText('awesomse image'); 218 | ``` 219 | 220 | #### ByTitle 221 | 222 | `ByTitle` 은 `title` 속성을 가지고 있는 DOM 혹은 `title` 엘리먼트를 지니고있는 SVG 를 선택 할 때 사용합니다. 223 | 224 | > title 속성은 html 에서 툴팁을 보여줘야 하는 상황에 사용하곤 합니다. 225 | 226 | ```jsx 227 |

228 | 리액트는 짱 멋진 라이브러리다. 229 |

230 | 231 | 232 | Delete 233 | 234 | 235 | 236 | const spanReact = getByTitle('React'); 237 | const svgDelete = getByTitle('Delete'); 238 | ``` 239 | 240 | #### ByDisplayValue 241 | 242 | `ByDisplayValue` 는 `input`, `textarea`, `select` 가 지니고 있는 현재 값을 가지고 엘리먼트를 선택합니다. 243 | 244 | ```jsx 245 | ; 246 | 247 | const input = getByDisplayValue('text'); 248 | ``` 249 | 250 | #### ByRole 251 | 252 | `ByRole`은 특정 `role` 값을 지니고 있는 엘리먼트를 선택합니다. 253 | 254 | ```jsx 255 | 삭제; 256 | 257 | const spanRemove = getByRole('button'); 258 | ``` 259 | 260 | #### ByTestId 261 | 262 | `ByTestId` 는 다른 방법으로 못 선택할때 사용하는 방법인데요, 특정 DOM 에 직접 test 할 때 사용할 id 를 달아서 선택하는 것을 의미합니다. 263 | 264 | ```jsx 265 |
흔한 div
; 266 | 267 | const commonDiv = getByTestId('commondiv'); 268 | ``` 269 | 270 | !> **주의**: camelCase 가 아닙니다. 값을 설정할때 `data-testid="..."` 이렇게 설정하셔야합니다. 추가적으로, `ByTestId` 는 다른 방법으로 선택할 수 없을때에만 사용해야합니다. 271 | 272 | ### 어떤 쿼리를 사용해야 할까? 273 | 274 | 쿼리의 종류가 정말 많죠? 그렇다면, 어떤 쿼리를 우선적으로 사용해야 할까요? [매뉴얼](https://testing-library.com/docs/guide-which-query) 에서는 다음 우선순위를 따라서 사용하는것을 권장하고있습니다. 275 | 276 | 1. getByLabelText 277 | 2. getByPlaceholderText 278 | 3. getByText 279 | 4. getByDisplayValue 280 | 5. getByAltText 281 | 6. getByTitle 282 | 7. getByRole 283 | 8. getByTestId 284 | 285 | 그리고, DOM 의 `querySelector` 를 사용 할 수도 있는데요, 이는 지양해야합니다. 차라리 `data-testid` 를 설정하는것이 좋습니다. 286 | 287 | ```javascript 288 | const utils = render(); 289 | const element = utils.container.querySelector('.my-class'); 290 | ``` 291 | 292 | ## Counter 컴포넌트 테스트 코드 작성하기 293 | 294 | 이번에는 Counter 컴포넌트를 만들고, 이를 위한 테스트 코드를 작성해봅시다. 295 | 296 | 먼저 Counter.js 파일을 생성해서 다음 코드를 작성하세요. 297 | 298 | #### `src/Counter.js` 299 | 300 | ```jsx 301 | import React, { useState, useCallback } from 'react'; 302 | 303 | const Counter = () => { 304 | const [number, setNumber] = useState(0); 305 | 306 | const onIncrease = useCallback(() => { 307 | setNumber(number + 1); 308 | }, [number]); 309 | 310 | const onDecrease = useCallback(() => { 311 | setNumber(number - 1); 312 | }, [number]); 313 | 314 | return ( 315 |
316 |

{number}

317 | 318 | 319 |
320 | ); 321 | }; 322 | 323 | export default Counter; 324 | ``` 325 | 326 | 그리고, 이 컴포넌트를 App 에서 렌더링하여 잘 작동하는지 확인해보세요. 327 | 328 | ```jsx 329 | import React from 'react'; 330 | import Counter from './Counter'; 331 | 332 | const App = () => { 333 | return ; 334 | }; 335 | 336 | export default App; 337 | ``` 338 | 339 | ![](https://i.imgur.com/sZ3P8xg.png) 340 | 341 | 잘 보여졌나요? 그럼 Counter를 위한 테스트 코드를 작성해보겠습니다. 342 | 343 | #### `src/Counter.test.js` 344 | 345 | ```jsx 346 | import React from 'react'; 347 | import { render, fireEvent } from 'react-testing-library'; 348 | import Counter from './Counter'; 349 | 350 | describe('', () => { 351 | it('matches snapshot', () => { 352 | const utils = render(); 353 | expect(utils.container).toMatchSnapshot(); 354 | }); 355 | it('has a number and two buttons', () => { 356 | const utils = render(); 357 | // 버튼과 숫자가 있는지 확인 358 | utils.getByText('0'); 359 | utils.getByText('+1'); 360 | utils.getByText('-1'); 361 | }); 362 | it('increases', () => { 363 | const utils = render(); 364 | const number = utils.getByText('0'); 365 | const plusButton = utils.getByText('+1'); 366 | // 클릭 이벤트를 두번 발생시키기 367 | fireEvent.click(plusButton); 368 | fireEvent.click(plusButton); 369 | expect(number).toHaveTextContent('2'); // jest-dom 의 확장 matcher 사용 370 | expect(number.textContent).toBe('2'); // textContent 를 직접 비교 371 | }); 372 | it('decreases', () => { 373 | const utils = render(); 374 | const number = utils.getByText('0'); 375 | const plusButton = utils.getByText('-1'); 376 | // 클릭 이벤트를 두번 발생시키기 377 | fireEvent.click(plusButton); 378 | fireEvent.click(plusButton); 379 | expect(number).toHaveTextContent('-2'); // jest-dom 의 확장 matcher 사용 380 | }); 381 | }); 382 | ``` 383 | 384 | ### 이벤트 다루기 385 | 386 | 여기서 `fireEvent()` 라는 함수를 불러와서 사용했는데요, 이 함수는 이벤트를 발생시켜줍니다. 사용법은 다음과 같습니다. 387 | 388 | ```js 389 | fireEvent.이벤트이름(DOM, 이벤트객체); 390 | ``` 391 | 392 | 클릭 이벤트의 경우엔 이벤트객체를 따로 넣어주지 않아도 되지만, 예를 들어서 change 이벤트의 경우엔 다음과 같이 해주어야합니다. 393 | 394 | ```js 395 | fireEvent.change(myInput, { target: { value: 'hello world' } }); 396 | ``` 397 | 398 | 이제 react-testing-library 의 주요 기능을 벌써 다 배우셨습니다! 399 | 400 | > 아직 다루지 않은 내용은 비동기 작업인데요, 이에 대한 내용은 나중에 이어질 섹션에서 다루게 됩니다. 401 | -------------------------------------------------------------------------------- /docs/07-async-test.md: -------------------------------------------------------------------------------- 1 | # 7. 비동기 작업을 위한 테스트 2 | 3 | 리액트 애플리케이션에서 비동기 작업이 있을 때는 이를 어떻게 테스팅 하는지, 그리고 API 요청을 해야 하는 경우 이를 어떻게 mock 할 수 있는지에 대해서 알아보겠습니다. 4 | 5 | 우리가 이전에 만들었던 rtl-tutorial 프로젝트 디렉터리를 다시 에디터로 열어주세요. 6 | 7 | > 해당 프로젝트를 만들지 않았다면 [리액트 프로젝트 만들기](/05-react-testing-library?id=리액트-프로젝트-만들기) 부분을 참고하세요. 8 | 9 | ## 비동기적으로 바뀌는 컴포넌트 UI 테스트 10 | 11 | DelayedToggle 라는 컴포넌트를 만들어보세요. 12 | 13 | #### `src/DelayedToggle.js` 14 | 15 | ```jsx 16 | import React, { useState, useCallback } from 'react'; 17 | 18 | const DelayedToggle = () => { 19 | const [toggle, setToggle] = useState(false); 20 | // 1초 후 toggle 값을 반전시키는 함수 21 | const onToggle = useCallback(() => { 22 | setTimeout(() => { 23 | setToggle(toggle => !toggle); 24 | }, 1000); 25 | }, []); 26 | return ( 27 |
28 | 29 |
30 | 상태: {toggle ? 'ON' : 'OFF'} 31 |
32 | {toggle &&
야호!!
} 33 |
34 | ); 35 | }; 36 | 37 | export default DelayedToggle; 38 | ``` 39 | 40 | 컴포넌트를 만드셨으면 App 에서 렌더링하고 `yarn start` 를 해서 브라우저에 띄운 후 버튼을 눌러보세요. 41 | 42 | #### `src/App.js` 43 | 44 | ```jsx 45 | import React from 'react'; 46 | import DelayedToggle from './DelayedToggle'; 47 | 48 | const App = () => { 49 | return ; 50 | }; 51 | 52 | export default App; 53 | ``` 54 | 55 | 이 컴포넌트는 다음과 같이 작동합니다. 56 | 57 | ![](https://i.imgur.com/lTFlbxZ.gif) 58 | 59 | 버튼이 클릭되면 1초후 상태 값이 바뀌고, 상태가 ON 일때는 "야호!!" 라는 텍스트가 보여집니다. 60 | 61 | 이런 컴포넌트의 테스트는 어떻게 작성 할 수 있는지 알아봅시다. 62 | 63 | ### Async Utilities 64 | 65 | 이런 테스트는 `react-testing-library` 에서 지원하는 [Async Utilities](https://testing-library.com/docs/dom-testing-library/api-async) 함수들을 사용하여 작성 할 수 있습니다. 66 | 67 | Aync Utilities 는 총 4가지 함수가 있는데요, 각 함수들을 직접 사용해보면서 사용법을 익혀봅시다. 68 | 69 | #### wait 70 | 71 | ```js 72 | function wait( 73 | callback?: () => void, 74 | options?: { 75 | timeout?: number 76 | interval?: number 77 | } 78 | ): Promise 79 | ``` 80 | 81 | [`wait`](https://testing-library.com/docs/dom-testing-library/api-async#wait) 함수를 사용하면 특정 콜백에서 에러를 발생하지 않을 때 까지 대기할 수 있습니다. DelayedToggle 컴포넌트의 테스트 케이스를 다음과 같이 만들어보세요. 82 | 83 | #### `src/DelayedToggle.test.js` 84 | 85 | ```jsx 86 | import React from 'react'; 87 | import DelayedToggle from './DelayedToggle'; 88 | import { 89 | render, 90 | fireEvent, 91 | wait, 92 | waitForElement, 93 | waitForDomChange, 94 | waitForElementToBeRemoved 95 | } from 'react-testing-library'; 96 | 97 | describe('', () => { 98 | it('reveals text when toggle is ON', async () => { 99 | const { getByText } = render(); 100 | const toggleButton = getByText('토글'); 101 | fireEvent.click(toggleButton); 102 | await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다. 103 | }); 104 | }); 105 | ``` 106 | 107 | `wait`함수는 콜백 안의 함수가 에러가 발생시키지 않을 때 까지 기다리다가, 대기시간이 timeout 을 초과하게 되면 테스트 케이스가 실패합니다. timeout 은 기본값 4500ms이며, 이는 다음과 같이 커스터마이징을 할 수 있습니다. 108 | 109 | ```js 110 | await wait(() => getByText('야호!!'), { timeout: 3000 }); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다. 111 | ``` 112 | 113 | 이제 `yarn test` 를 입력하면 테스트가 진행이 될텐데, 리액트 16.8을 쓰고 계시다면 다음과 같은 경고가 발생 할 것입니다. 114 | 115 | ``` 116 | ● Console 117 | 118 | console.error node_modules/react-dom/cjs/react-dom.development.js:506 119 | Warning: An update to DelayedToggle inside a test was not wrapped in act(...). 120 | 121 | When testing, code that causes React state updates should be wrapped into act(...): 122 | 123 | act(() => { 124 | /* fire events that update state */ 125 | }); 126 | /* assert on the output */ 127 | 128 | This ensures that you're testing the behavior the user would see in the browser. Learn more at https://fb.me/react-wrap-tests-with-act 129 | in DelayedToggle (at DelayedToggle.test.js:14) 130 | ``` 131 | 132 | 이는 리액트 16.9 에서는 고쳐지는 버그인데요, 아직 릴리즈되지는 않았습니다. 따라서, 이 경고를 숨기기 위하여 setupTests.js 파일을 다음과같이 수정해보세요. 133 | 134 | #### `src/setupTests.js` 135 | 136 | ```js 137 | import 'react-testing-library/cleanup-after-each'; 138 | import 'jest-dom/extend-expect'; 139 | 140 | // this is just a little hack to silence a warning that we'll get until react 141 | // fixes this: https://github.com/facebook/react/pull/14853 142 | const originalError = console.error; 143 | beforeAll(() => { 144 | console.error = (...args) => { 145 | if (/Warning.*not wrapped in act/.test(args[0])) { 146 | return; 147 | } 148 | originalError.call(console, ...args); 149 | }; 150 | }); 151 | 152 | afterAll(() => { 153 | console.error = originalError; 154 | }); 155 | ``` 156 | 157 | 작성 후 테스트 CLI 를 종료 후 다시 실행하세요. 158 | 159 | #### waitForElement 160 | 161 | ```js 162 | function waitForElement( 163 | callback: () => T, 164 | options?: { 165 | container?: HTMLElement 166 | timeout?: number 167 | mutationObserverOptions?: MutationObserverInit 168 | } 169 | ): Promise 170 | ``` 171 | 172 | [`waitForElement`](https://testing-library.com/docs/dom-testing-library/api-async#waitforelement) 함수는 특정 엘리먼트가, 나타났거나, 바뀌었거나, 사라질때까지 대기를 해줍니다. 그리고 프로미스가 끝날 때 우리가 선택한 엘리먼트를 resolve 합니다. 173 | 174 | DelayedToggle 컴포넌트의 텍스트가 바뀌는 것을 검증하는 테스트 케이스를 `waitForElement` 로 한번 구현을 해보겠습니다. 175 | 176 | #### `src/DelayedToggle.test.js` 177 | 178 | ```jsx 179 | import React from 'react'; 180 | import DelayedToggle from './DelayedToggle'; 181 | import { 182 | render, 183 | fireEvent, 184 | wait, 185 | waitForElement, 186 | waitForDomChange, 187 | waitForElementToBeRemoved 188 | } from 'react-testing-library'; 189 | 190 | describe('', () => { 191 | it('reveals text when toggle is ON', async () => { 192 | const { getByText } = render(); 193 | const toggleButton = getByText('토글'); 194 | fireEvent.click(toggleButton); 195 | await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다. 196 | }); 197 | 198 | it('toggles text ON/OFF', async () => { 199 | const { getByText } = render(); 200 | const toggleButton = getByText('토글'); 201 | fireEvent.click(toggleButton); 202 | const text = await waitForElement(() => getByText('ON')); 203 | expect(text).toHaveTextContent('ON'); 204 | }); 205 | }); 206 | ``` 207 | 208 | #### waitForDomChange 209 | 210 | ```js 211 | function waitForDomChange(options?: { 212 | container?: HTMLElement 213 | timeout?: number 214 | mutationObserverOptions?: MutationObserverInit 215 | }): Promise 216 | ``` 217 | 218 | [`waitForDomChange`](https://testing-library.com/docs/dom-testing-library/api-async#waitfordomchange)의 특징은, 콜백함수가 아니라 검사하고 싶은 엘리먼트를 넣어주면 해당 엘리먼트에서 변화가 발생 할 때 까지 기다려준다는 것 입니다. 우리가 `render` 를 했을때 결과값에 있는 `container` 를 넣어주면, 사전에 쿼리를 통하여 엘리먼트를 선택하지 않아도 변화가 발생했음을 감지할 수 있습니다. 또한, 프로미스가 resolve 됐을 땐 [`mutationList`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/MutationObserver) 를 반환하여 DOM이 어떻게 바뀌었는지에 대한 정보를 알수있습니다. 219 | 220 | #### `src/DelayedToggle.test.js` 221 | 222 | ```jsx 223 | import React from 'react'; 224 | import DelayedToggle from './DelayedToggle'; 225 | import { 226 | render, 227 | fireEvent, 228 | wait, 229 | waitForElement, 230 | waitForDomChange, 231 | waitForElementToBeRemoved 232 | } from 'react-testing-library'; 233 | 234 | describe('', () => { 235 | it('reveals text when toggle is ON', async () => { 236 | const { getByText } = render(); 237 | const toggleButton = getByText('토글'); 238 | fireEvent.click(toggleButton); 239 | await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다. 240 | }); 241 | 242 | it('toggles text ON/OFF', async () => { 243 | const { getByText } = render(); 244 | const toggleButton = getByText('토글'); 245 | fireEvent.click(toggleButton); 246 | const text = await waitForElement(() => getByText('ON')); 247 | expect(text).toHaveTextContent('ON'); 248 | }); 249 | 250 | it('changes something when button is clicked', async () => { 251 | const { getByText, container } = render(); 252 | const toggleButton = getByText('토글'); 253 | fireEvent.click(toggleButton); 254 | const mutations = await waitForDomChange({ container }); 255 | console.log(mutations); 256 | }); 257 | }); 258 | ``` 259 | 260 | #### waitForElementToBeRemoved 261 | 262 | ```js 263 | function waitForElementToBeRemoved( 264 | callback: () => T, 265 | options?: { 266 | container?: HTMLElement 267 | timeout?: number 268 | mutationObserverOptions?: MutationObserverInit 269 | } 270 | ): Promise 271 | ``` 272 | 273 | [`waitForElementToBeRemove`](https://testing-library.com/docs/dom-testing-library/api-async#waitforelementtoberemoved)는 특정 엘리먼트가 화면에서 사라질때까지 기다리는 함수입니다. 274 | 275 | #### `src/DelayedToggle.test.js` 276 | 277 | ```jsx 278 | import React from 'react'; 279 | import DelayedToggle from './DelayedToggle'; 280 | import { 281 | render, 282 | fireEvent, 283 | wait, 284 | waitForElement, 285 | waitForDomChange, 286 | waitForElementToBeRemoved 287 | } from 'react-testing-library'; 288 | 289 | describe('', () => { 290 | it('reveals text when toggle is ON', async () => { 291 | const { getByText } = render(); 292 | const toggleButton = getByText('토글'); 293 | fireEvent.click(toggleButton); 294 | await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다. 295 | }); 296 | 297 | it('toggles text ON/OFF', async () => { 298 | const { getByText } = render(); 299 | const toggleButton = getByText('토글'); 300 | fireEvent.click(toggleButton); 301 | const text = await waitForElement(() => getByText('ON')); 302 | expect(text).toHaveTextContent('ON'); 303 | }); 304 | 305 | it('changes something when button is clicked', async () => { 306 | const { getByText, container } = render(); 307 | const toggleButton = getByText('토글'); 308 | fireEvent.click(toggleButton); 309 | const mutations = await waitForDomChange({ container }); 310 | }); 311 | 312 | it('removes text when toggle is OFF', async () => { 313 | const { getByText, container } = render(); 314 | const toggleButton = getByText('토글'); 315 | fireEvent.click(toggleButton); 316 | await waitForDomChange({ container }); // ON 이 됨 317 | getByText('야호!!'); 318 | fireEvent.click(toggleButton); 319 | await waitForElementToBeRemoved(() => getByText('야호!!')); 320 | }); 321 | }); 322 | ``` 323 | 324 | 이제, 컴포넌트의 UI 가 비동기적으로 바뀔 때 어떻게 처리해야 되는지 잘 알겠지요? 325 | 326 | ## REST API 호출하는 경우의 테스트 327 | 328 | 이번에는 리액트 컴포넌트에서 REST API 를 연동하는 경우 어떻게 테스트를 해야하는지 알아봅시다. 329 | 330 | 테스트 할 컴포넌트를 먼저 만들어봅시다! 331 | 332 | 우선 HTTP Client 라이브러리인 axios 를 설치하세요. 333 | 334 | ``` 335 | $ yarn add axios 336 | ``` 337 | 338 | 우리는 [JSONPlaceholder](https://jsonplaceholder.typicode.com/) 에서 제공하는 가짜 API 를 사용하겠습니다. 339 | 340 | #### API 예시 341 | 342 | ``` 343 | GET https://jsonplaceholder.typicode.com/users/1 344 | 345 | { 346 | "id": 1, 347 | "name": "Leanne Graham", 348 | "username": "Bret", 349 | "email": "Sincere@april.biz", 350 | "address": { 351 | "street": "Kulas Light", 352 | "suite": "Apt. 556", 353 | "city": "Gwenborough", 354 | "zipcode": "92998-3874", 355 | "geo": { 356 | "lat": "-37.3159", 357 | "lng": "81.1496" 358 | } 359 | }, 360 | "phone": "1-770-736-8031 x56442", 361 | "website": "hildegard.org", 362 | "company": { 363 | "name": "Romaguera-Crona", 364 | "catchPhrase": "Multi-layered client-server neural-net", 365 | "bs": "harness real-time e-markets" 366 | } 367 | } 368 | ``` 369 | 370 | ### 예제 컴포넌트 만들기 371 | 372 | id 값을 props 로 받아오면, 위 API 를 호출하고 결과에서 username 과 email 을 보여주는 컴포넌트를 만들어봅시다. 373 | 374 | #### `src/UserProfile.js` 375 | 376 | ```jsx 377 | import React, { useEffect, useState } from 'react'; 378 | import axios from 'axios'; 379 | 380 | const UserProfile = ({ id }) => { 381 | const [userData, setUserData] = useState(null); 382 | const [loading, setLoading] = useState(false); 383 | const getUser = async id => { 384 | setLoading(true); 385 | try { 386 | const response = await axios.get( 387 | `https://jsonplaceholder.typicode.com/users/${id}` 388 | ); 389 | setUserData(response.data); 390 | } catch (e) { 391 | console.log(e); 392 | } 393 | setLoading(false); 394 | }; 395 | useEffect(() => { 396 | getUser(id); 397 | }, [id]); 398 | 399 | if (loading) return
로딩중..
; 400 | 401 | if (!userData) return null; 402 | const { username, email } = userData; 403 | 404 | return ( 405 |
406 |

407 | Username: 408 | {username} 409 |

410 |

411 | Email: 412 | {email} 413 |

414 |
415 | ); 416 | }; 417 | 418 | export default UserProfile; 419 | ``` 420 | 421 | 컴포넌트를 만드셨으면, App 에서 렌더링해서 이 컴포넌트가 어떻게 작동하는지 확인해보세요. 422 | 423 | ![](https://i.imgur.com/ENgpVXh.gif) 424 | 425 | 이렇게 REST API 를 호출해야 하는 컴포넌트의 경우, 테스트 코드에서도 똑같이 요청을 보낼 수는 있지만, 일반적으로 서버에 API 를 직접 호출하지는 않고 이를 mocking 합니다. 왜냐하면, 서버의 API 가 실제로 작동하고 안하고는 서버쪽의 일이기 때문이기 때문입니다. 426 | 427 | 때문에, axios 를 사용했을 때 실제로 요청이 발생하지는 않지만 마치 발생한것처럼 작동하게 하는 방법이 있는데요, 대표적으로 두가지가 있는데 [node_modules 를 mocking](https://www.leighhalliday.com/mocking-axios-in-jest-testing-async-functions) 하는 방법이 있고, [axios-mock-adapter](https://www.npmjs.com/package/axios-mock-adapter) 라는 라이브러리를 쓰는 방법이 있습니다. 428 | 429 | 우리는 `axios-mock-adapter` 를 사용하겠습니다. 라이브러리를 사용하는편이 준비해야 할 코드도 적고 훨씬 편리합니다. 430 | 431 | ### axios-mock-adapter 사용해보기 432 | 433 | UserProfile 의 테스트 코드를 다음과 같이 작성해보세요. 434 | 435 | #### `src/UserProfile.test.js` 436 | 437 | ```jsx 438 | import React from 'react'; 439 | import { render } from 'react-testing-library'; 440 | import UserProfile from './UserProfile'; 441 | import axios from 'axios'; 442 | import MockAdapter from 'axios-mock-adapter'; 443 | 444 | describe('', () => { 445 | const mock = new MockAdapter(axios, { delayResponse: 200 }); // 200ms 가짜 딜레이 설정 446 | // API 요청에 대하여 응답 미리 정하기 447 | mock.onGet('https://jsonplaceholder.typicode.com/users/1').reply(200, { 448 | id: 1, 449 | name: 'Leanne Graham', 450 | username: 'Bret', 451 | email: 'Sincere@april.biz', 452 | address: { 453 | street: 'Kulas Light', 454 | suite: 'Apt. 556', 455 | city: 'Gwenborough', 456 | zipcode: '92998-3874', 457 | geo: { 458 | lat: '-37.3159', 459 | lng: '81.1496' 460 | } 461 | }, 462 | phone: '1-770-736-8031 x56442', 463 | website: 'hildegard.org', 464 | company: { 465 | name: 'Romaguera-Crona', 466 | catchPhrase: 'Multi-layered client-server neural-net', 467 | bs: 'harness real-time e-markets' 468 | } 469 | }); 470 | it('loads userData properly', () => { 471 | // TODO 472 | }); 473 | }); 474 | ``` 475 | 476 | `MockAdapter` 를 사용하면 특정 API 요청이 발생했을 때 어떤 응답이 와야 하는지 직접 정의해줄 수 있습니다. 그러면, 컴포넌트 내부에서 API 요청이 발생하게 될 때, 실제로 서버까지 요청이 날라가지 않고, 우리가 정의한 가짜 응답을 사용하게 됩니다. 477 | 478 | MockAdapter 를 사용 할 때는 `delayResponse` 옵션을 설정하면 딜레이를 임의적으로 설정할 수 있습니다. 이 설정은 없어도 상관 없습니다. 479 | 480 | 이렇게 axios 요청을 mocking 한 이후에는 우리가 이전에 배웠던 Async Utilities 를 사용해주면 됩니다. 481 | 482 | #### `src/UserProfile.test.js` 483 | 484 | ```jsx 485 | import React from 'react'; 486 | import { render, waitForElement } from 'react-testing-library'; 487 | import UserProfile from './UserProfile'; 488 | import axios from 'axios'; 489 | import MockAdapter from 'axios-mock-adapter'; 490 | 491 | describe('', () => { 492 | const mock = new MockAdapter(axios, { delayResponse: 200 }); // 200ms 가짜 딜레이 설정 493 | // API 요청에 대하여 응답 미리 정하기 494 | mock.onGet('https://jsonplaceholder.typicode.com/users/1').reply(200, { 495 | id: 1, 496 | name: 'Leanne Graham', 497 | username: 'Bret', 498 | email: 'Sincere@april.biz', 499 | address: { 500 | street: 'Kulas Light', 501 | suite: 'Apt. 556', 502 | city: 'Gwenborough', 503 | zipcode: '92998-3874', 504 | geo: { 505 | lat: '-37.3159', 506 | lng: '81.1496' 507 | } 508 | }, 509 | phone: '1-770-736-8031 x56442', 510 | website: 'hildegard.org', 511 | company: { 512 | name: 'Romaguera-Crona', 513 | catchPhrase: 'Multi-layered client-server neural-net', 514 | bs: 'harness real-time e-markets' 515 | } 516 | }); 517 | it('calls getUser API loads userData properly', async () => { 518 | const { getByText } = render(); 519 | await waitForElement(() => getByText('로딩중..')); // 로딩중.. 문구 보여줘야함 520 | await waitForElement(() => getByText('Bret')); // Bret (username) 을 보여줘야함 521 | }); 522 | }); 523 | ``` 524 | 525 | 테스트가 잘 통과했나요? 526 | 527 | ### axios-mock-adapter 활용방법 528 | 529 | `axios-mock-adapter` 의 [공식 문서](https://www.npmjs.com/package/axios-mock-adapter)를 보면 더 많은 활용방법을 볼 수 있는데요, 그 중 일부를 어떤 용도로 사용 할 수 있는지 소개시켜드리겠습니다. 530 | 531 | #### 한번만 mocking 하기 - replyOnce 532 | 533 | ```js 534 | mock.onGet('/users').replyOnce(200, users); 535 | ``` 536 | 537 | 이렇게 하면 요청을 딱 한번만 mocking 할 수 있습니다. 한번 요청을 하고 나면 그 다음 요청은 정상적으로 요청이 됩니다. 538 | 539 | #### replyOnce 를 연달아서 사용하기 540 | 541 | ```js 542 | mock 543 | .onGet('/users') 544 | .replyOnce(200, users) // 첫번째 요청 545 | .onGet('/users') 546 | .replyOnce(500); // 두번째 요청 547 | ``` 548 | 549 | 이렇게 하면 첫번째 요청과 두번째 요청을 연달아서 설정 할 수 있습니다. 요청을 여러번 해야 하는 경우 이런 형태로 구현하시면 됩니다. 550 | 551 | #### 아무 요청이나 mocking 하기 - onAny() 552 | 553 | 보통 메서드에 따라 `onGet()`, `onPost()` 이런식으로 사용하는데요, `onAny()` 를 사용하면 어떤 메서드던 mocking 을 할 수 있습니다. 554 | 555 | ```js 556 | mock.onAny('/foo').reply(200); 557 | ``` 558 | 559 | 만약에 주소까지 생략하면 어떤 주소던 mocking 합니다. 560 | 561 | ```js 562 | mock.onAny().reply(200); 563 | ``` 564 | 565 | #### reset 과 restore 566 | 567 | mock 인스턴스에는 `reset` 과 `restore` 라는 함수가 있습니다. 568 | 569 | ```js 570 | mock.reset(); 571 | ``` 572 | 573 | `reset` 은 mock 인스턴스에 등록된 모든 mock 핸들러를 제거합니다. 만약에 테스트 케이스별로 다른 mock 설정을 하고 싶으시면 이 함수를 사용하시면 됩니다. 574 | 575 | ```js 576 | mock.restore(); 577 | ``` 578 | 579 | `restore` 은 axios 에서 mocking 기능을 완전히 제거합니다. 만약에 실제 테스트를 하다가 요청이 실제로 날라가게 하고 싶으면 이 함수를 사용하면 됩니다. 580 | -------------------------------------------------------------------------------- /docs/08-redux-test.md: -------------------------------------------------------------------------------- 1 | # 리덕스를 사용하는 리액트 프로젝트 테스트 2 | 3 | 이번에는 리덕스를 사용하는 리액트 프로젝트에서는 어떻게 테스트를 작성하는지 배워봅시다. 4 | 5 | > 만약 리덕스를 잘 모르신다면 이 [링크](https://velog.io/@velopert/Redux-1-%EC%86%8C%EA%B0%9C-%EB%B0%8F-%EA%B0%9C%EB%85%90%EC%A0%95%EB%A6%AC-zxjlta8ywt) 를 통하여 리덕스를 배우신 뒤 이 튜토리얼을 진행해주세요. 6 | 7 | 리덕스를 사용 할 때 테스트 해야 되는 것들은 다음과 같습니다. 8 | 9 | 1. 액션 생성 함수 10 | 2. 리듀서 11 | 3. 프리젠테이셔널(Presentational) 컴포넌트 12 | 4. 컨테이너(Container) 컴포넌트 13 | 14 | > 여기서 프리젠테이셔널 컴포넌트는 리덕스에 연결되지 않은 컴포넌트를 의미하고, 컨테이너 컴포넌트는 리덕스에 연결된 컴포넌트를 의미합니다. 15 | 16 | 1번과 2번은 별도의 라이브러리 도움 없이 테스트 코드를 작성하시면 되고, 3번과 4번의 경우엔 react-testing-library 를 사용하면 됩니다. 4번, 컨테이너 컴포넌트를 위한 테스트를 작성하는건 꽤나 간단합니다. 리덕스가 연결되어있다고 해서 크게 어려워지는 것은 없습니다. 그냥 컴포넌트에 리덕스 스토어를 연결해주기만 하면 됩니다. 17 | 18 | ## 프로젝트 새로 만들기 19 | 20 | 리덕스를 사용하는 리액트 프로젝트를 위한 테스트 코드를 작성해보기 전에 우선 샘플 리덕스 코드를 준비해주겠습니다. 기존의 rtl-tutorial 프로젝트에는 연습용 컴포넌트들이 너무 많아졌으니, 새로운 프로젝트를 만들어주세요. 21 | 22 | ```bash 23 | $ yarn create react-app redux-test-tutorial 24 | ``` 25 | 26 | 그리고, 해당 디렉터리에 들어가서 테스트 관련 라이브러리와 리덕스 관련 라이브러리들을 설치하세요. 27 | 28 | ```bash 29 | $ yarn add react-testing-library jest-dom redux react-redux 30 | ``` 31 | 32 | 다음, src 디렉터리에 `setupTests.js` 도 만들어주세요. 33 | 34 | #### `src/setupTests.js` 35 | 36 | ```javascript 37 | import 'react-testing-library/cleanup-after-each'; 38 | import 'jest-dom/extend-expect'; 39 | ``` 40 | 41 | 이제 프로젝트에 리덕스를 적용해주겠습니다. 우리는 [Ducks 패턴](https://github.com/JisuPark/ducks-modular-redux)을 사용해서 리덕스 모듈을 작성 할 건데요, 지금 당장은 주요 리덕스 관련 코드를 작성하지는 않고 파일만 만들어주겠습니다. 42 | 43 | > 이 부분은 현재 작성중입니다..! 강의에서는 코드로 설명합니다 ;) 44 | 45 | [![Edit redux-test-tutorial](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/reduxtesttutorial-0lbut?fontsize=14) 46 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # 벨로퍼트와 함께하는 리액트 테스팅 2 | 3 | 이 튜토리얼에서는 리액트 프로젝트에서 TDD (Test Driven Development · 테스트 주도 개발)을 하는 방법에 대해서 알아보겠습니다. 소프트웨어 개발에서의 "테스트" 라는 개념에 대해서 1도 모르는 개발자도 이 튜토리얼을 마치고나면, "나는 리액트 테스팅을 잘 할 수 있다!" 라고 말 할 수 있습니다. 4 | 5 | > 이 튜토리얼을 진행하기 전에는 [Node.js](https://nodejs.org/ko/) 와 [yarn](https://yarnpkg.com/lang/en/) 이 설치되어있어야 합니다. yarn 을 사용하는것을 권장드리지만, yarn 을 싫어하신다면 npm 을 쓰셔도 상관없습니다. 6 | 7 | ## 테스트란? 8 | 9 | 테스트란, 우리가 작성한 코드가 잘 작동한다는 것을 검증하는 작업을 의미합니다. 특정 기능이 잘 작동하는지 확인하려면 우리는 어떻게 해야할까요? 가장 기본적인 방법으로는 우리가 구현한 기능을 직접 사용해보는 것입니다. 예를 들어서 직접 마우스로 눌러보고, 키보드로 입력해서 우리의 의도대로 잘 작동하는건지 확인하는 것이죠. 10 | 그런데 우리가 만든 프로젝트의 모든 기능을 사람이 수동으로 하나하나 확인하는 것은 정말 번거로운 일 입니다. 그래서 우리는 테스트 자동화라는 작업을 합니다. 사람이 직접 확인을 하는 것이 아니라 테스트를 하는 코드를 작성해서, 테스트 시스템이 자동으로 확인을 해줄 수 있게 하는 것이죠. 이를 테스트 자동화라고 합니다. 11 | 12 | ## 테스트 자동화를 통해 얻을 수 있는 이점 13 | 14 | 테스트 자동화를 함으로서 어떤 이점을 얻을 수 있을까요? 만약 여러분들이 프로젝트를 다른 사람들과 협업을 하게 되는 경우 테스트 코드를 작성하는 것은 매우 큰 도움을 줍니다. 예를들어 여러분이 코드 A 를 작성하고, 다른 개발자가 코드 B 를 작성했다고 가정해봅시다. 어느날 여러분이 코드를 수정하면서 A 코드와 B 코드를 조금씩 수정했는데, 여러분이 직접 확인 할 때는 모두 잘 작동하는지 알았는데, 알고보니 사소한 실수를 했다거나 어떠한 상황을 고려하지 못해서 B 코드의 기능의 일부가 고장나있을 수도 있습니다. 그런데 그걸 캐치하지 못하고 서비스가 배포되어 치명적인 버그가 있는 상태로 사용자에게 제공되었다면? 매우 안타까운 일이겠죠? 만약에 프로젝트의 규모가 정말 커서 고려해야 할 사항이 많거나, 여러명이 작업을 진행 한 코드여서 코드를 수정한 사람이 해당 코드를 모두 제대로 파악하고 있지 않을 때 이러한 일이 발생하기 쉽습니다. 그런데 만약 우리가 테스트 자동화를 했더라면 우리가 준비해놓은 상황에 대하여 자동으로 빠르게 검사를 해줄 수 있기 때문에 코드가 이전과 똑같이 작동하는지 아니면 고장났는지 쉽게 판단을 할 수 있어서 이러한 안타까운 일을 방지 할 수 있습니다. 15 | 16 | 테스트 코드를 사용하면 우리가 프로젝트를 개발하는 과정에서 우리가 써내려가는 코드가 기존의 기능들을 실수로 망가뜨리는것을 아주 효과적으로 방지 할 수 있습니다. 또한 개발하게 될 떄 실제 발생 할 수 있게 되는 상황에 대하여 미리 정리해놓고 그에 맞춰 코드를 작성하게 되면 우리가 실수로 빠뜨릴 수 있는 사항들을 까먹지 않고 잘 챙길 수 있게 됩니다. 17 | 18 | 이 뿐만이 아닙니다. 코드를 리팩토링 할 때 정말 좋습니다. 예를 들어서 우리가 A 라는 기능을 리팩토링한다고 가정해봅시다. 리팩토링을 하고 있는 기능이 규모가 커다란 기능이라면 실수로 빠뜨릴 수 있는 사항도 있을 수 있습니다. 그래서, 리팩토링 이후에는 버그가 있는지 없는지 정말 세밀하게 또 확인을 해봐야하죠. 그런데? 테스트 코드가 존재한다면, 리팩토링 이후에 코드가 이전과 똑같이 작동하는지 검증하는게 매우 쉬워지기 때문에 코드의 질을 향상시키는 것에 매우 큰 도움이 됩니다. 19 | 20 | 테스트 코드를 작성한다고해서 프로젝트에서 버그가 발생하지 않는 것은 아닙니다. 테스트 코드를 작성해도, 프로젝트에는 버그가 발생할 수 있습니다. 하지만, 만약 버그가 발생했더라면, 그 버그를 고치고 나서, 버그가 발생하는 상황에 대한 테스트 코드를 작성해두면, 두번 다시 똑같은 실수를 하는 것을 방지할 수 있습니다. 21 | 22 | ## 유닛 테스트와 통합 테스트 23 | 24 | 테스트 코드는 크게 두 종류로 나뉘어질 수 있습니다. 25 | 26 | ### 유닛(Unit) 테스트 27 | 28 | 첫번째는 유닛 테스트입니다. 유닛 테스트는 아주 조그마한 단위로 작성됩니다. 한번 유닛 테스트의 예시들을 확인해볼까요? 29 | 30 | - 컴포넌트가 잘 렌더링된다. 31 | - 컴포넌트의 특정 함수를 실행하면 상태가 우리가 원하는 형태로 바뀐다 32 | - 리덕스의 액션 생성 함수가 액션 객체를 잘 만들어낸다 33 | - 리덕스의 리듀서에 상태와 액션객체를 넣어서 호출하면 새로운 상태를 잘 만들어준다. 34 | 35 | 프로젝트의 기능을 잘게잘게 쪼개서 테스트를 하면 각 기능이 모두 잘 작동하는지 확인 할 수는 있습니다. 그런데, 전체적으로 잘 작동하는지 확인이 잘 안될 수도 있습니다. 36 | 37 | ![](https://cdn-images-1.medium.com/max/1600/1*KoTFh3xRPgkzD0FlzsYKjA.gif) 38 | 39 | ![](https://cdn-images-1.medium.com/max/1600/1*4-T6VVnULaszi9ydHQ7Sfw.gif) 40 | 41 | ![](https://media2.giphy.com/media/i5RWkVZzVScmY/giphy.gif?cid=790b76115ce903e8786752696b73dcb3&rid=giphy.gif) 42 | 43 | ### 통합(Integrated) 테스트 44 | 45 | 기능들이 전체적으로 잘 작동하는지 확인하기 위해서 사용 하는 것이 바로 통합 테스트입니다. 통합 테스트에 대한 예시도 확인해볼까요? 46 | 47 | - 여러 컴포넌트들을 렌더링하고 서로 상호 작용을 잘 하고 있다 48 | - DOM 이벤트를 발생 시켰을 때 우리의 UI 에 원하는 변화가 잘 발생한다 49 | - 리덕스와 연동된 컨테이너 컴포넌트의 DOM 에 특정 이벤트를 발생시켰을 때 우리가 원하는 액션이 잘 디스패치 된다 50 | 51 | 유닛 테스트와 통합 테스트 간의 차이는 간단합니다. 유닛 테스트는 하나에 초점을 둔다면, 통합 테스트는 이름이 그러하듯 여러 요소들을 고려하여 작성합니다. 유닛 테스트는 보통 한 파일만 불러와서 진행하는 반면, 통합 테스트는 여러 요소들을 고려하는 과정에서, 여러 파일들을 불러와서 사용하게 될 수도 있습니다. 추가적으로, 한 파일에 있는 여러 기능들을 함께 사용하는 것도 통합테스트로 간주됩니다. 52 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - [벨로퍼트와 함께하는 리액트 테스팅](/) 4 | - [1. 자바스크립트 테스팅의 기초](01-javascript-testing.md) 5 | - [2. TDD의 소개](02-tdd-introduction.md) 6 | - [3. 리액트 컴포넌트의 테스트](03-react-test.md) 7 | - [4. Enzyme 사용법](04-enzyme.md) 8 | - [5. React-testing-library 사용법](05-react-testing-library.md) 9 | - [6. TDD 개발 흐름으로 투두리스트 만들기](06-rtl-tdd.md) 10 | - [7. 비동기 작업을 위한 테스트](07-async-test.md) 11 | - [8. 리덕스를 사용하는 리액트 프로젝트 테스트](08-redux-test.md) 12 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 벨로퍼트와 함께하는 리액트 테스팅 6 | 7 | 8 | 12 | 13 | 14 | 15 |
16 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/learn-react-testing/6c006c222684ac0048c3dcb14fc4bf3bdaf77c3b/docs/reference.md -------------------------------------------------------------------------------- /javascript/jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/7c/_wgf_n092337x7g32fgtrghc0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // An array of directory names to be searched recursively up from the requiring module's location 64 | // moduleDirectories: [ 65 | // "node_modules" 66 | // ], 67 | 68 | // An array of file extensions your modules use 69 | // moduleFileExtensions: [ 70 | // "js", 71 | // "json", 72 | // "jsx", 73 | // "ts", 74 | // "tsx", 75 | // "node" 76 | // ], 77 | 78 | // A map from regular expressions to module names that allow to stub out resources with a single module 79 | // moduleNameMapper: {}, 80 | 81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 82 | // modulePathIgnorePatterns: [], 83 | 84 | // Activates notifications for test results 85 | // notify: false, 86 | 87 | // An enum that specifies notification mode. Requires { notify: true } 88 | // notifyMode: "failure-change", 89 | 90 | // A preset that is used as a base for Jest's configuration 91 | // preset: null, 92 | 93 | // Run tests from one or more projects 94 | // projects: null, 95 | 96 | // Use this configuration option to add custom reporters to Jest 97 | // reporters: undefined, 98 | 99 | // Automatically reset mock state between every test 100 | // resetMocks: false, 101 | 102 | // Reset the module registry before running each individual test 103 | // resetModules: false, 104 | 105 | // A path to a custom resolver 106 | // resolver: null, 107 | 108 | // Automatically restore mock state between every test 109 | // restoreMocks: false, 110 | 111 | // The root directory that Jest should scan for tests and modules within 112 | // rootDir: null, 113 | 114 | // A list of paths to directories that Jest should use to search for files in 115 | // roots: [ 116 | // "" 117 | // ], 118 | 119 | // Allows you to use a custom runner instead of Jest's default test runner 120 | // runner: "jest-runner", 121 | 122 | // The paths to modules that run some code to configure or set up the testing environment before each test 123 | // setupFiles: [], 124 | 125 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 126 | // setupFilesAfterEnv: [], 127 | 128 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 129 | // snapshotSerializers: [], 130 | 131 | // The test environment that will be used for testing 132 | testEnvironment: "node", 133 | 134 | // Options that will be passed to the testEnvironment 135 | // testEnvironmentOptions: {}, 136 | 137 | // Adds a location field to test results 138 | // testLocationInResults: false, 139 | 140 | // The glob patterns Jest uses to detect test files 141 | // testMatch: [ 142 | // "**/__tests__/**/*.[jt]s?(x)", 143 | // "**/?(*.)+(spec|test).[tj]s?(x)" 144 | // ], 145 | 146 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 147 | // testPathIgnorePatterns: [ 148 | // "/node_modules/" 149 | // ], 150 | 151 | // The regexp pattern or array of patterns that Jest uses to detect test files 152 | // testRegex: [], 153 | 154 | // This option allows the use of a custom results processor 155 | // testResultsProcessor: null, 156 | 157 | // This option allows use of a custom test runner 158 | // testRunner: "jasmine2", 159 | 160 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 161 | // testURL: "http://localhost", 162 | 163 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 164 | // timers: "real", 165 | 166 | // A map from regular expressions to paths to transformers 167 | // transform: null, 168 | 169 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 170 | // transformIgnorePatterns: [ 171 | // "/node_modules/" 172 | // ], 173 | 174 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 175 | // unmockedModulePathPatterns: undefined, 176 | 177 | // Indicates whether each individual test should be reported during the run 178 | // verbose: null, 179 | 180 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 181 | // watchPathIgnorePatterns: [], 182 | 183 | // Whether to use watchman for file crawling 184 | // watchman: true, 185 | }; 186 | -------------------------------------------------------------------------------- /javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "javascript", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@types/jest": "^24.0.13", 8 | "jest": "^24.8.0" 9 | }, 10 | "scripts": { 11 | "test": "jest --watchAll --verbose" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /javascript/stats.js: -------------------------------------------------------------------------------- 1 | exports.max = numbers => Math.max(...numbers); 2 | exports.min = numbers => Math.min(...numbers); 3 | exports.avg = numbers => 4 | numbers.reduce( 5 | (acc, current, index, { length }) => acc + current / length, 6 | 0 7 | ); 8 | 9 | exports.sort = numbers => numbers.sort((a, b) => a - b); 10 | exports.median = numbers => { 11 | const { length } = numbers; 12 | const middle = Math.floor(length / 2); 13 | return length % 2 14 | ? numbers[middle] 15 | : (numbers[middle - 1] + numbers[middle]) / 2; 16 | }; 17 | 18 | exports.mode = numbers => { 19 | const counts = numbers.reduce( 20 | (acc, current) => acc.set(current, acc.get(current) + 1 || 1), 21 | new Map() 22 | ); 23 | 24 | const maxCount = Math.max(...counts.values()); 25 | const modes = [...counts.keys()].filter( 26 | number => counts.get(number) === maxCount 27 | ); 28 | 29 | if (modes.length === numbers.length) { 30 | // 최빈값이 없음 31 | return null; 32 | } 33 | 34 | if (modes.length > 1) { 35 | // 최빈값이 여러개 36 | return modes; 37 | } 38 | 39 | // 최빈값이 하나 40 | return modes[0]; 41 | }; 42 | -------------------------------------------------------------------------------- /javascript/stats.test.js: -------------------------------------------------------------------------------- 1 | const stats = require('./stats'); 2 | 3 | describe('stats', () => { 4 | it('gets maximum value', () => { 5 | expect(stats.max([1, 2, 3, 4])).toBe(4); 6 | }); 7 | it('gets minimum value', () => { 8 | expect(stats.min([1, 2, 3, 4])).toBe(1); 9 | }); 10 | it('gets average value', () => { 11 | expect(stats.avg([1, 2, 3, 4, 5])).toBe(3); 12 | }); 13 | describe('median', () => { 14 | it('sorts the array', () => { 15 | expect(stats.sort([5, 4, 1, 2, 3])).toEqual([1, 2, 3, 4, 5]); 16 | }); 17 | it('gets the median for odd length', () => { 18 | expect(stats.median([1, 2, 3, 4, 5])).toBe(3); 19 | }); 20 | it('gets the median for even length', () => { 21 | expect(stats.median([1, 2, 3, 4, 5, 6])).toBe(3.5); 22 | }); 23 | }); 24 | describe('mode', () => { 25 | it('has one mode', () => { 26 | expect(stats.mode([1, 2, 2, 2, 3])).toBe(2); 27 | }); 28 | it('has no mode', () => { 29 | expect(stats.mode([1, 2, 3])).toBe(null); 30 | }); 31 | it('has multiple mode', () => { 32 | expect(stats.mode([1, 2, 2, 3, 3, 4])).toEqual([2, 3]); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /javascript/sum.js: -------------------------------------------------------------------------------- 1 | function sum(a, b) { 2 | return a + b; 3 | } 4 | 5 | function sumOf(numbers) { 6 | return numbers.reduce((acc, current) => acc + current, 0); 7 | } 8 | 9 | // 각각 내보내기 10 | exports.sum = sum; 11 | exports.sumOf = sumOf; 12 | -------------------------------------------------------------------------------- /javascript/sum.test.js: -------------------------------------------------------------------------------- 1 | const { sum, sumOf } = require('./sum'); 2 | 3 | describe('sum', () => { 4 | it('calculates 1 + 2', () => { 5 | expect(sum(1, 2)).toBe(3); 6 | }); 7 | 8 | it('calculates all numbers', () => { 9 | const array = [1, 2, 3, 4, 5]; 10 | expect(sumOf(array)).toBe(15); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /react-enzyme-test/.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 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /react-enzyme-test/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /react-enzyme-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "^24.0.13", 7 | "enzyme": "^3.9.0", 8 | "enzyme-adapter-react-16": "^1.13.1", 9 | "enzyme-to-json": "^3.3.5", 10 | "react": "^16.8.6", 11 | "react-dom": "^16.8.6", 12 | "react-scripts": "3.0.1" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | }, 35 | "jest": { 36 | "snapshotSerializers": [ 37 | "enzyme-to-json/serializer" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /react-enzyme-test/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/learn-react-testing/6c006c222684ac0048c3dcb14fc4bf3bdaf77c3b/react-enzyme-test/public/favicon.ico -------------------------------------------------------------------------------- /react-enzyme-test/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /react-enzyme-test/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /react-enzyme-test/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /react-enzyme-test/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HookCounter from './HookCounter'; 4 | 5 | function App() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default App; 14 | -------------------------------------------------------------------------------- /react-enzyme-test/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /react-enzyme-test/src/Counter.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | class Counter extends Component { 4 | state = { 5 | number: 0 6 | }; 7 | handleIncrease = () => { 8 | this.setState({ 9 | number: this.state.number + 1 10 | }); 11 | }; 12 | handleDecrease = () => { 13 | this.setState({ 14 | number: this.state.number - 1 15 | }); 16 | }; 17 | render() { 18 | return ( 19 |
20 |

{this.state.number}

21 | 22 | 23 |
24 | ); 25 | } 26 | } 27 | 28 | export default Counter; 29 | -------------------------------------------------------------------------------- /react-enzyme-test/src/Counter.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Counter from './Counter'; 4 | 5 | describe('', () => { 6 | it('matches snapshot', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper).toMatchSnapshot(); 9 | }); 10 | it('has initial number', () => { 11 | const wrapper = shallow(); 12 | expect(wrapper.state().number).toBe(0); 13 | }); 14 | it('increases', () => { 15 | const wrapper = shallow(); 16 | wrapper.instance().handleIncrease(); 17 | expect(wrapper.state().number).toBe(1); 18 | }); 19 | it('decreases', () => { 20 | const wrapper = shallow(); 21 | wrapper.instance().handleDecrease(); 22 | expect(wrapper.state().number).toBe(-1); 23 | }); 24 | it('calls handleIncrease', () => { 25 | // 클릭이벤트를 시뮬레이트하고, state 를 확인 26 | const wrapper = shallow(); 27 | const plusButton = wrapper.findWhere( 28 | node => node.type() === 'button' && node.text() === '+1' 29 | ); 30 | plusButton.simulate('click'); 31 | expect(wrapper.state().number).toBe(1); 32 | }); 33 | it('calls handleDecrease', () => { 34 | // 클릭 이벤트를 시뮬레이트하고, h2 태그의 텍스트 확인 35 | const wrapper = shallow(); 36 | 37 | const minusButton = wrapper.findWhere( 38 | node => node.type() === 'button' && node.text() === '-1' 39 | ); 40 | minusButton.simulate('click'); 41 | 42 | const number = wrapper.find('h2'); 43 | expect(number.text()).toBe('-1'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /react-enzyme-test/src/HookCounter.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | 3 | const HookCounter = () => { 4 | const [number, setNumber] = useState(0); 5 | const onIncrease = useCallback(() => { 6 | setNumber(number + 1); 7 | }, [number]); 8 | const onDecrease = useCallback(() => { 9 | setNumber(number - 1); 10 | }, [number]); 11 | 12 | return ( 13 |
14 |

{number}

15 | 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default HookCounter; 22 | -------------------------------------------------------------------------------- /react-enzyme-test/src/HookCounter.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import HookCounter from './HookCounter'; 4 | 5 | describe('', () => { 6 | it('matches snapshot', () => { 7 | const wrapper = mount(); 8 | expect(wrapper).toMatchSnapshot(); 9 | }); 10 | it('increases', () => { 11 | const wrapper = mount(); 12 | let plusButton = wrapper.findWhere( 13 | node => node.type() === 'button' && node.text() === '+1' 14 | ); 15 | plusButton.simulate('click'); 16 | plusButton.simulate('click'); 17 | 18 | const number = wrapper.find('h2'); 19 | 20 | expect(number.text()).toBe('2'); 21 | }); 22 | it('decreases', () => { 23 | const wrapper = mount(); 24 | let decreaseButton = wrapper.findWhere( 25 | node => node.type() === 'button' && node.text() === '-1' 26 | ); 27 | decreaseButton.simulate('click'); 28 | decreaseButton.simulate('click'); 29 | 30 | const number = wrapper.find('h2'); 31 | 32 | expect(number.text()).toBe('-2'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /react-enzyme-test/src/Profile.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Profile = ({ username, name }) => { 4 | return ( 5 |
6 | {username}  7 | ({name}) 8 |
9 | ); 10 | }; 11 | 12 | export default Profile; 13 | -------------------------------------------------------------------------------- /react-enzyme-test/src/Profile.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import Profile from './Profile'; 4 | 5 | describe('', () => { 6 | it('matches snapshot', () => { 7 | const wrapper = mount(); 8 | expect(wrapper).toMatchSnapshot(); 9 | }); 10 | it('renders username and name', () => { 11 | const wrapper = mount(); 12 | 13 | expect(wrapper.props().username).toBe('velopert'); 14 | expect(wrapper.props().name).toBe('김민준'); 15 | 16 | const boldElement = wrapper.find('b'); 17 | expect(boldElement.contains('velopert')).toBe(true); 18 | const spanElement = wrapper.find('span'); 19 | expect(spanElement.text()).toBe('(김민준)'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /react-enzyme-test/src/__snapshots__/Counter.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` matches snapshot 1`] = ` 4 |
5 |

6 | 0 7 |

8 | 13 | 18 |
19 | `; 20 | -------------------------------------------------------------------------------- /react-enzyme-test/src/__snapshots__/HookCounter.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` matches snapshot 1`] = ` 4 | 5 |
6 |

7 | 0 8 |

9 | 14 | 19 |
20 |
21 | `; 22 | -------------------------------------------------------------------------------- /react-enzyme-test/src/__snapshots__/Profile.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` matches snapshot 1`] = ` 4 | 8 |
9 | 10 | velopert 11 | 12 |   13 | 14 | ( 15 | 김민준 16 | ) 17 | 18 |
19 |
20 | `; 21 | -------------------------------------------------------------------------------- /react-enzyme-test/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /react-enzyme-test/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /react-enzyme-test/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /react-enzyme-test/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /react-enzyme-test/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /redux-test-tutorial/.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 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /redux-test-tutorial/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /redux-test-tutorial/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-test-tutorial", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "^24.0.13", 7 | "jest-dom": "^3.4.0", 8 | "react": "^16.8.6", 9 | "react-dom": "^16.8.6", 10 | "react-redux": "^7.0.3", 11 | "react-scripts": "3.0.1", 12 | "react-testing-library": "^7.0.1", 13 | "redux": "^4.0.1" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /redux-test-tutorial/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/learn-react-testing/6c006c222684ac0048c3dcb14fc4bf3bdaf77c3b/redux-test-tutorial/public/favicon.ico -------------------------------------------------------------------------------- /redux-test-tutorial/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /redux-test-tutorial/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CounterContainer from './containers/CounterContainer'; 3 | 4 | const App = () => { 5 | return ; 6 | }; 7 | 8 | export default App; 9 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => {}); 6 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/components/Counter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Counter = ({ number, onIncrease, onDecrease }) => { 4 | return ( 5 |
6 |

{number}

7 | 8 | 9 |
10 | ); 11 | }; 12 | 13 | export default Counter; 14 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/components/Counter.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from 'react-testing-library'; 3 | import Counter from './Counter'; 4 | 5 | describe('', () => { 6 | const setup = (props = {}) => { 7 | const initialProps = { number: 0 }; 8 | const utils = render(); 9 | const number = utils.getByText( 10 | (props.number || initialProps.number).toString() 11 | ); 12 | const plusButton = utils.getByText('+1'); 13 | const minusButton = utils.getByText('-1'); 14 | return { 15 | ...utils, 16 | number, 17 | plusButton, 18 | minusButton 19 | }; 20 | }; 21 | it('should have number and two buttons', () => { 22 | const { number, plusButton, minusButton } = setup(); 23 | expect(number).toBeTruthy(); 24 | expect(plusButton).toBeTruthy(); 25 | expect(minusButton).toBeTruthy(); 26 | }); 27 | it('should render number props', () => { 28 | const { number } = setup({ number: 7 }); 29 | expect(number).toHaveTextContent('7'); 30 | }); 31 | it('should call onIncrease and onDecrease', () => { 32 | const onIncrease = jest.fn(); 33 | const onDecrease = jest.fn(); 34 | const { plusButton, minusButton } = setup({ 35 | onIncrease, 36 | onDecrease 37 | }); 38 | fireEvent.click(plusButton); 39 | expect(onIncrease).toBeCalled(); 40 | fireEvent.click(minusButton); 41 | expect(onDecrease).toBeCalled(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/containers/CounterContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { increase, decrease } from '../modules/counter'; 4 | import Counter from '../components/Counter'; 5 | 6 | const CounterContainer = ({ number, increase, decrease }) => { 7 | return ( 8 | 9 | ); 10 | }; 11 | 12 | const mapStateToProps = ({ counter }) => ({ 13 | number: counter.number 14 | }); 15 | 16 | const mapDispatchToProps = { 17 | increase, 18 | decrease 19 | }; 20 | 21 | export default connect( 22 | mapStateToProps, 23 | mapDispatchToProps 24 | )(CounterContainer); 25 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/containers/CounterContainer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderWithRedux from '../renderWithRedux'; 3 | import CounterContainer from './CounterContainer'; 4 | import { fireEvent } from 'react-testing-library'; 5 | 6 | describe('', () => { 7 | it('shows the default number 0', () => { 8 | const { getByText } = renderWithRedux(); 9 | getByText('0'); 10 | }); 11 | it('should increase when +1 is clicked', () => { 12 | const { getByText } = renderWithRedux(); 13 | fireEvent.click(getByText('+1')); 14 | getByText('1'); 15 | }); 16 | it('should decrease when -1 is clicked', () => { 17 | const { getByText } = renderWithRedux(); 18 | const number = getByText('0'); 19 | fireEvent.click(getByText('-1')); 20 | expect(number).toHaveTextContent('-1'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { createStore } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | 6 | import './index.css'; 7 | import App from './App'; 8 | import * as serviceWorker from './serviceWorker'; 9 | import rootReducer from './modules'; 10 | 11 | const store = createStore(rootReducer); 12 | 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | document.getElementById('root') 18 | ); 19 | 20 | // If you want your app to work offline and load faster, you can change 21 | // unregister() to register() below. Note this comes with some pitfalls. 22 | // Learn more about service workers: https://bit.ly/CRA-PWA 23 | serviceWorker.unregister(); 24 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/modules/counter.js: -------------------------------------------------------------------------------- 1 | const INCREASE = 'counter/INCREASE'; 2 | const DECREASE = 'counter/DECREASE'; 3 | 4 | export const increase = () => ({ type: INCREASE }); 5 | export const decrease = () => ({ type: DECREASE }); 6 | 7 | const initialState = { 8 | number: 0 9 | }; 10 | function counter(state = initialState, action) { 11 | switch (action.type) { 12 | case INCREASE: 13 | return { 14 | number: state.number + 1 15 | }; 16 | case DECREASE: 17 | return { 18 | number: state.number - 1 19 | }; 20 | default: 21 | return state; 22 | } 23 | } 24 | 25 | export default counter; 26 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/modules/counter.test.js: -------------------------------------------------------------------------------- 1 | import counter, * as actions from './counter'; 2 | 3 | describe('counter module', () => { 4 | describe('action creators', () => { 5 | it('should create INCREASE action', () => { 6 | expect(actions.increase()).toEqual({ type: 'counter/INCREASE' }); 7 | }); 8 | it('should create DECREASE action', () => { 9 | expect(actions.decrease()).toEqual({ type: 'counter/DECREASE' }); 10 | }); 11 | }); 12 | describe('reducer', () => { 13 | const initialState = { 14 | number: 0 15 | }; 16 | it('should have initialState', () => { 17 | const state = counter(undefined, {}); 18 | expect(state).toEqual(initialState); 19 | }); 20 | it('should handle INCREASE action', () => { 21 | const state = counter(initialState, actions.increase()); 22 | expect(state.number).toBe(1); 23 | }); 24 | it('should handle DECREASE action', () => { 25 | const state = counter(initialState, actions.decrease()); 26 | expect(state.number).toBe(-1); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/modules/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import counter from './counter'; 3 | 4 | const rootReducer = combineReducers({ 5 | counter 6 | }); 7 | 8 | export default rootReducer; 9 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/renderWithRedux.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-testing-library'; 3 | import { createStore } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import rootReducer from './modules'; 6 | 7 | function renderWithRedux(ui, initialState) { 8 | const store = createStore(rootReducer, initialState); 9 | const utils = render({ui}); 10 | return { 11 | ...utils, 12 | store 13 | }; 14 | } 15 | 16 | export default renderWithRedux; 17 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /redux-test-tutorial/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import 'react-testing-library/cleanup-after-each'; 2 | import 'jest-dom/extend-expect'; 3 | -------------------------------------------------------------------------------- /rtl-tdd-todos/.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 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /rtl-tdd-todos/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /rtl-tdd-todos/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtl-tdd-todos", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "^24.0.13", 7 | "jest-dom": "^3.4.0", 8 | "react": "^16.8.6", 9 | "react-dom": "^16.8.6", 10 | "react-scripts": "3.0.1", 11 | "react-testing-library": "^7.0.1" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "build": "react-scripts build", 16 | "test": "react-scripts test", 17 | "eject": "react-scripts eject" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /rtl-tdd-todos/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/learn-react-testing/6c006c222684ac0048c3dcb14fc4bf3bdaf77c3b/rtl-tdd-todos/public/favicon.ico -------------------------------------------------------------------------------- /rtl-tdd-todos/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /rtl-tdd-todos/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoApp from './TodoApp'; 3 | 4 | const App = () => { 5 | return ; 6 | }; 7 | 8 | export default App; 9 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/TodoApp.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useRef } from 'react'; 2 | import TodoList from './TodoList'; 3 | import TodoForm from './TodoForm'; 4 | 5 | const TodoApp = () => { 6 | const [todos, setTodos] = useState([ 7 | { 8 | id: 1, 9 | text: 'TDD 배우기', 10 | done: true 11 | }, 12 | { 13 | id: 2, 14 | text: 'react-testing-library 배우기', 15 | done: true 16 | } 17 | ]); 18 | const nextId = useRef(3); // 새로 추가 할 항목에서 사용 할 id 19 | 20 | const onInsert = useCallback(text => { 21 | // 새 항목 추가 후 22 | setTodos(todos => 23 | todos.concat({ 24 | id: nextId.current, 25 | text, 26 | done: false 27 | }) 28 | ); 29 | // nextId 값에 1 더하기 30 | nextId.current += 1; 31 | }, []); 32 | 33 | const onToggle = useCallback(id => { 34 | setTodos(todos => 35 | todos.map(todo => (todo.id === id ? { ...todo, done: !todo.done } : todo)) 36 | ); 37 | }, []); 38 | 39 | const onRemove = useCallback( 40 | id => { 41 | setTodos(todos => todos.filter(todo => todo.id !== id)); 42 | }, 43 | [] 44 | ); 45 | 46 | return ( 47 | <> 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default TodoApp; 55 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/TodoApp.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoApp from './TodoApp'; 3 | import { render, fireEvent } from 'react-testing-library'; 4 | 5 | describe('', () => { 6 | it('renders TodoForm and TodoList', () => { 7 | const { getByText, getByTestId } = render(); 8 | getByText('등록'); // TodoForm 존재유무 확인 9 | getByTestId('TodoList'); // TodoList 존재유무 확인 10 | }); 11 | it('renders two defaults todos', () => { 12 | const { getByText } = render(); 13 | getByText('TDD 배우기'); 14 | getByText('react-testing-library 배우기'); 15 | }); 16 | it('creates new todo', () => { 17 | const { getByPlaceholderText, getByText } = render(); 18 | // 이벤트를 발생시켜서 새 항목을 추가하면 19 | fireEvent.change(getByPlaceholderText('할 일을 입력하세요'), { 20 | target: { 21 | value: '새 항목 추가하기' 22 | } 23 | }); 24 | fireEvent.click(getByText('등록')); 25 | // 해당 항목이 보여져야합니다. 26 | getByText('새 항목 추가하기'); 27 | }); 28 | it('toggles todo', () => { 29 | const { getByText } = render(); 30 | // TDD 배우기 항목에 클릭 이벤트를 발생시키고 text-decoration 속성이 설정되는지 확인 31 | const todoText = getByText('TDD 배우기'); 32 | expect(todoText).toHaveStyle('text-decoration: line-through;'); 33 | fireEvent.click(todoText); 34 | expect(todoText).not.toHaveStyle('text-decoration: line-through;'); 35 | fireEvent.click(todoText); 36 | expect(todoText).toHaveStyle('text-decoration: line-through;'); 37 | }); 38 | it('removes todo', () => { 39 | const { getByText } = render(); 40 | const todoText = getByText('TDD 배우기'); 41 | const removeButton = todoText.nextSibling; 42 | fireEvent.click(removeButton); 43 | expect(todoText).not.toBeInTheDocument(); // 페이지에서 사라졌음을 의미함 44 | /* 45 | 또 다른 방법: 46 | const removedText = queryByText('TDD 배우기'); 47 | expect(removedText).toBeFalsy(); 48 | */ 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/TodoForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | 3 | const TodoForm = ({ onInsert }) => { 4 | const [value, setValue] = useState(''); 5 | const onChange = useCallback(e => { 6 | setValue(e.target.value); 7 | }, []); 8 | const onSubmit = useCallback( 9 | e => { 10 | onInsert(value); 11 | setValue(''); 12 | e.preventDefault(); // 새로고침을 방지함 13 | }, 14 | [onInsert, value] 15 | ); 16 | 17 | return ( 18 |
19 | 24 | 25 |
26 | ); 27 | }; 28 | 29 | export default TodoForm; 30 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/TodoForm.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from 'react-testing-library'; 3 | import TodoForm from './TodoForm'; 4 | 5 | describe('', () => { 6 | const setup = (props = {}) => { 7 | const utils = render(); 8 | const { getByText, getByPlaceholderText } = utils; 9 | const input = getByPlaceholderText('할 일을 입력하세요'); // input 이 있는지 확인 10 | const button = getByText('등록'); // button이 있는지 확인 11 | return { 12 | ...utils, 13 | input, 14 | button 15 | }; 16 | }; 17 | 18 | it('has input and a button', () => { 19 | const { input, button } = setup(); 20 | expect(input).toBeTruthy(); // 해당 값이 truthy 한 값인지 확인 21 | expect(button).toBeTruthy(); 22 | }); 23 | it('changes input', () => { 24 | const { input } = setup(); 25 | fireEvent.change(input, { 26 | target: { 27 | value: 'TDD 배우기' 28 | } 29 | }); 30 | expect(input).toHaveAttribute('value', 'TDD 배우기'); 31 | }); 32 | it('calls onInsert and clears input', () => { 33 | const onInsert = jest.fn(); 34 | const { input, button } = setup({ onInsert }); // props 가 필요 할땐 이렇게 직접 파라미터로 전달 35 | // 수정하고 36 | fireEvent.change(input, { 37 | target: { 38 | value: 'TDD 배우기' 39 | } 40 | }); 41 | // 버튼 클릭 42 | fireEvent.click(button); 43 | expect(onInsert).toBeCalledWith('TDD 배우기'); // onInsert 가 'TDD 배우기' 파라미터가 호출됐어야함 44 | expect(input).toHaveAttribute('value', ''); // input이 비워져야함 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/TodoItem.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | const TodoItem = ({ todo, onToggle, onRemove }) => { 4 | const { id, text, done } = todo; 5 | const toggle = useCallback(() => onToggle(id), [id, onToggle]); 6 | const remove = useCallback(() => onRemove(id), [id, onRemove]); 7 | 8 | return ( 9 |
  • 10 | 16 | {text} 17 | 18 | 19 |
  • 20 | ); 21 | }; 22 | 23 | export default React.memo(TodoItem); 24 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/TodoItem.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoItem from './TodoItem'; 3 | import { render } from 'react-testing-library'; 4 | import { fireEvent } from 'react-testing-library/dist'; 5 | 6 | describe('', () => { 7 | const sampleTodo = { 8 | id: 1, 9 | text: 'TDD 배우기', 10 | done: false 11 | }; 12 | const setup = (props = {}) => { 13 | const initialProps = { todo: sampleTodo }; 14 | const utils = render(); 15 | const { getByText } = utils; 16 | const todo = props.todo || initialProps.todo; 17 | const span = getByText(todo.text); 18 | const button = getByText('삭제'); 19 | return { 20 | ...utils, 21 | span, 22 | button 23 | }; 24 | }; 25 | it('has span and button', () => { 26 | const { span, button } = setup(); 27 | expect(span).toBeTruthy(); 28 | expect(button).toBeTruthy(); 29 | }); 30 | it('shows line-through on span when done is true', () => { 31 | const { span } = setup({ todo: { ...sampleTodo, done: true } }); 32 | expect(span).toHaveStyle('text-decoration: line-through;'); 33 | }); 34 | it('does not show line-through on span when done is false', () => { 35 | const { span } = setup({ todo: { ...sampleTodo, done: false } }); 36 | expect(span).not.toHaveStyle('text-decoration: line-through;'); 37 | }); 38 | it('calls onToggle', () => { 39 | const onToggle = jest.fn(); 40 | const { span } = setup({ onToggle }); 41 | fireEvent.click(span); 42 | expect(onToggle).toBeCalledWith(sampleTodo.id); 43 | }); 44 | it('calls onRemove', () => { 45 | const onRemove = jest.fn(); 46 | const { button } = setup({ onRemove }); 47 | fireEvent.click(button); 48 | expect(onRemove).toBeCalledWith(sampleTodo.id); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/TodoList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoItem from './TodoItem'; 3 | 4 | const TodoList = ({ todos, onToggle, onRemove }) => { 5 | return ( 6 |
      7 | {todos.map(todo => ( 8 | 14 | ))} 15 |
    16 | ); 17 | }; 18 | 19 | export default TodoList; 20 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/TodoList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TodoList from './TodoList'; 3 | import { render } from 'react-testing-library'; 4 | import { fireEvent } from 'react-testing-library/dist'; 5 | 6 | describe('', () => { 7 | const sampleTodos = [ 8 | { 9 | id: 1, 10 | text: 'TDD 배우기', 11 | done: true 12 | }, 13 | { 14 | id: 2, 15 | text: 'react-testing-library 배우기', 16 | done: true 17 | } 18 | ]; 19 | it('renders todos properly', () => { 20 | const { getByText } = render(); 21 | getByText(sampleTodos[0].text); 22 | getByText(sampleTodos[1].text); 23 | }); 24 | it('calls onToggle and onRemove', () => { 25 | const onToggle = jest.fn(); 26 | const onRemove = jest.fn(); 27 | const { getByText, getAllByText } = render( 28 | 29 | ); 30 | 31 | fireEvent.click(getByText(sampleTodos[0].text)); 32 | expect(onToggle).toBeCalledWith(sampleTodos[0].id); 33 | 34 | fireEvent.click(getAllByText('삭제')[0]); // 첫번째 삭제 버튼을 클릭 35 | expect(onRemove).toBeCalledWith(sampleTodos[0].id); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /rtl-tdd-todos/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import 'react-testing-library/cleanup-after-each'; 2 | import 'jest-dom/extend-expect'; 3 | -------------------------------------------------------------------------------- /rtl-tutorial/.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 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /rtl-tutorial/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
    10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
    13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
    18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
    23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
    26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /rtl-tutorial/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtl-tutorial", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "^24.0.13", 7 | "axios": "^0.18.0", 8 | "axios-mock-adapter": "^1.16.0", 9 | "jest-dom": "^3.4.0", 10 | "react": "^16.8.6", 11 | "react-dom": "^16.8.6", 12 | "react-scripts": "3.0.1", 13 | "react-testing-library": "^7.0.1" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.2%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /rtl-tutorial/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/velopert/learn-react-testing/6c006c222684ac0048c3dcb14fc4bf3bdaf77c3b/rtl-tutorial/public/favicon.ico -------------------------------------------------------------------------------- /rtl-tutorial/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
    27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /rtl-tutorial/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /rtl-tutorial/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /rtl-tutorial/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import UserProfile from './UserProfile'; 3 | 4 | const App = () => { 5 | return ; 6 | }; 7 | 8 | export default App; 9 | -------------------------------------------------------------------------------- /rtl-tutorial/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /rtl-tutorial/src/Counter.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | 3 | const Counter = () => { 4 | const [number, setNumber] = useState(0); 5 | const onIncrease = useCallback(() => { 6 | setNumber(number + 1); 7 | }, [number]); 8 | const onDecrease = useCallback(() => { 9 | setNumber(number - 1); 10 | }, [number]); 11 | 12 | return ( 13 |
    14 |

    {number}

    15 | 16 | 17 |
    18 | ); 19 | }; 20 | 21 | export default Counter; 22 | -------------------------------------------------------------------------------- /rtl-tutorial/src/Counter.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from 'react-testing-library'; 3 | import Counter from './Counter'; 4 | 5 | describe('', () => { 6 | it('matches snapshot', () => { 7 | const utils = render(); 8 | expect(utils.container).toMatchSnapshot(); 9 | }); 10 | it('has a number and two buttons', () => { 11 | const utils = render(); 12 | // 버튼과 숫자가 있는지 확인 13 | utils.getByText('0'); 14 | utils.getByText('+1'); 15 | utils.getByText('-1'); 16 | }); 17 | it('increases', () => { 18 | const utils = render(); 19 | const number = utils.getByText('0'); 20 | const plusButton = utils.getByText('+1'); 21 | // 클릭 이벤트를 두번 발생시키기 22 | fireEvent.click(plusButton); 23 | fireEvent.click(plusButton); 24 | expect(number).toHaveTextContent('2'); // jest-dom 의 확장 matcher 사용 25 | expect(number.textContent).toBe('2'); // textContent 를 직접 비교 26 | }); 27 | it('decreases', () => { 28 | const utils = render(); 29 | const number = utils.getByText('0'); 30 | const plusButton = utils.getByText('-1'); 31 | // 클릭 이벤트를 두번 발생시키기 32 | fireEvent.click(plusButton); 33 | fireEvent.click(plusButton); 34 | expect(number).toHaveTextContent('-2'); // jest-dom 의 확장 matcher 사용 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /rtl-tutorial/src/DelayedToggle.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | 3 | const DelayedToggle = () => { 4 | const [toggle, setToggle] = useState(false); 5 | // 1초 후 toggle 값을 반전시키는 함수 6 | const onToggle = useCallback(() => { 7 | setTimeout(() => { 8 | setToggle(toggle => !toggle); 9 | }, 1000); 10 | }, []); 11 | return ( 12 |
    13 | 14 |
    15 | 상태: {toggle ? 'ON' : 'OFF'} 16 |
    17 | {toggle &&
    야호!!
    } 18 |
    19 | ); 20 | }; 21 | 22 | export default DelayedToggle; 23 | -------------------------------------------------------------------------------- /rtl-tutorial/src/DelayedToggle.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import DelayedToggle from './DelayedToggle'; 3 | import { 4 | render, 5 | fireEvent, 6 | wait, 7 | waitForElement, 8 | waitForDomChange, 9 | waitForElementToBeRemoved 10 | } from 'react-testing-library'; 11 | 12 | describe('', () => { 13 | it('', () => {}); 14 | // it('reveals text when toggle is ON', async () => { 15 | // const { getByText } = render(); 16 | // const toggleButton = getByText('토글'); 17 | // fireEvent.click(toggleButton); 18 | // await wait(() => getByText('야호!!')); // 콜백 안의 함수가 에러를 발생시키지 않을 때 까지 기다립니다. 19 | // }); 20 | 21 | // it('toggles text ON/OFF', async () => { 22 | // const { getByText } = render(); 23 | // const toggleButton = getByText('토글'); 24 | // fireEvent.click(toggleButton); 25 | // const text = await waitForElement(() => getByText('ON')); 26 | // expect(text).toHaveTextContent('ON'); 27 | // }); 28 | 29 | // it('changes something when button is clicked', async () => { 30 | // const { getByText, container } = render(); 31 | // const toggleButton = getByText('토글'); 32 | // fireEvent.click(toggleButton); 33 | // const mutations = await waitForDomChange({ container }); 34 | // expect(mutations).not.toHaveLength(0); 35 | // }); 36 | 37 | // it('removes text when toggle is OFF', async () => { 38 | // const { getByText, container } = render(); 39 | // const toggleButton = getByText('토글'); 40 | // fireEvent.click(toggleButton); 41 | // await waitForDomChange({ container }); // ON 이 됨 42 | // getByText('야호!!'); 43 | // fireEvent.click(toggleButton); 44 | // await waitForElementToBeRemoved(() => getByText('야호!!')); 45 | // }); 46 | }); 47 | -------------------------------------------------------------------------------- /rtl-tutorial/src/Profile.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Profile = ({ username, name }) => { 4 | return ( 5 |
    6 | {username}  7 | ({name}) 8 |
    9 | ); 10 | }; 11 | 12 | export default Profile; 13 | -------------------------------------------------------------------------------- /rtl-tutorial/src/Profile.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-testing-library'; 3 | import Profile from './Profile'; 4 | 5 | describe('', () => { 6 | it('matches snapshot', () => { 7 | const utils = render(); 8 | expect(utils.container).toMatchSnapshot(); 9 | }); 10 | it('shows the props correctly', () => { 11 | const utils = render(); 12 | utils.getByText('velopert'); // velopert 라는 텍스트를 가진 엘리먼트가 있는지 확인 13 | utils.getByText('(김민준)'); // (김민준) 이라는 텍스트를 가진 엘리먼트가 있는지 확인 14 | utils.getByText(/김/); // 정규식 /김/ 을 통과하는 엘리먼트가 있는지 확인 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /rtl-tutorial/src/UserProfile.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import axios from 'axios'; 3 | 4 | const UserProfile = ({ id }) => { 5 | const [userData, setUserData] = useState(null); 6 | const [loading, setLoading] = useState(false); 7 | const getUser = async id => { 8 | setLoading(true); 9 | try { 10 | const response = await axios.get( 11 | `https://jsonplaceholder.typicode.com/users/${id}` 12 | ); 13 | setUserData(response.data); 14 | } catch (e) { 15 | console.log(e); 16 | } 17 | setLoading(false); 18 | }; 19 | useEffect(() => { 20 | getUser(id); 21 | }, [id]); 22 | 23 | if (loading) return
    로딩중..
    ; 24 | 25 | if (!userData) return null; 26 | const { username, email } = userData; 27 | 28 | return ( 29 |
    30 |

    31 | Username: 32 | {username} 33 |

    34 |

    35 | Email: 36 | {email} 37 |

    38 |
    39 | ); 40 | }; 41 | 42 | export default UserProfile; 43 | -------------------------------------------------------------------------------- /rtl-tutorial/src/UserProfile.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, waitForElement } from 'react-testing-library'; 3 | import UserProfile from './UserProfile'; 4 | import axios from 'axios'; 5 | import MockAdapter from 'axios-mock-adapter'; 6 | 7 | describe('', () => { 8 | const mock = new MockAdapter(axios, { delayResponse: 200 }); // 200ms 가짜 딜레이 설정 9 | // API 요청에 대하여 응답 미리 정하기 10 | mock.onGet('https://jsonplaceholder.typicode.com/users/1').reply(200, { 11 | id: 1, 12 | name: 'Leanne Graham', 13 | username: 'Bret', 14 | email: 'Sincere@april.biz', 15 | address: { 16 | street: 'Kulas Light', 17 | suite: 'Apt. 556', 18 | city: 'Gwenborough', 19 | zipcode: '92998-3874', 20 | geo: { 21 | lat: '-37.3159', 22 | lng: '81.1496' 23 | } 24 | }, 25 | phone: '1-770-736-8031 x56442', 26 | website: 'hildegard.org', 27 | company: { 28 | name: 'Romaguera-Crona', 29 | catchPhrase: 'Multi-layered client-server neural-net', 30 | bs: 'harness real-time e-markets' 31 | } 32 | }); 33 | it('calls getUser API loads userData properly', async () => { 34 | const { getByText } = render(); 35 | await waitForElement(() => getByText('로딩중..')); // 로딩중.. 문구 보여줘야함 36 | await waitForElement(() => getByText('Bret')); // Bret (username) 을 보여줘야함 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /rtl-tutorial/src/__snapshots__/Counter.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` matches snapshot 1`] = ` 4 |
    5 |
    6 |

    7 | 0 8 |

    9 | 12 | 15 |
    16 |
    17 | `; 18 | -------------------------------------------------------------------------------- /rtl-tutorial/src/__snapshots__/Profile.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` matches snapshot 1`] = ` 4 |
    5 |
    6 | 7 | velopert 8 | 9 |   10 | 11 | ( 12 | 김민준 13 | ) 14 | 15 |
    16 |
    17 | `; 18 | -------------------------------------------------------------------------------- /rtl-tutorial/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /rtl-tutorial/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /rtl-tutorial/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /rtl-tutorial/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /rtl-tutorial/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import 'react-testing-library/cleanup-after-each'; 2 | import 'jest-dom/extend-expect'; 3 | 4 | // this is just a little hack to silence a warning that we'll get until react 5 | // fixes this: https://github.com/facebook/react/pull/14853 6 | const originalError = console.error; 7 | beforeAll(() => { 8 | console.error = (...args) => { 9 | if (/Warning.*not wrapped in act/.test(args[0])) { 10 | return; 11 | } 12 | originalError.call(console, ...args); 13 | }; 14 | }); 15 | 16 | afterAll(() => { 17 | console.error = originalError; 18 | }); 19 | --------------------------------------------------------------------------------