├── src ├── react-app-env.d.ts ├── index.tsx ├── components │ ├── text │ │ ├── text.test.tsx │ │ └── text.tsx │ ├── app │ │ ├── app.test.tsx │ │ └── app.tsx │ ├── notes │ │ ├── hooks │ │ │ ├── use-notes.tsx │ │ │ └── use-notes.test.tsx │ │ ├── styles │ │ │ └── index.ts │ │ ├── notes.tsx │ │ └── notes.test.tsx │ └── icons │ │ └── index.tsx ├── styles │ ├── theme.ts │ └── global-style.ts ├── styled.d.ts └── utils │ └── test-utils.tsx ├── public ├── robots.txt ├── favicon.ico ├── logo192.png └── index.html ├── .travis.yml ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edceds/fully-testable-react-hooks/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edceds/fully-testable-react-hooks/HEAD/public/logo192.png -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | 4 | import { App } from "./components/app/app" 5 | 6 | ReactDOM.render(, document.getElementById("root")) 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "10" 5 | branches: 6 | only: 7 | - master 8 | cache: 9 | directories: 10 | - node_modules 11 | install: 12 | - yarn install 13 | script: 14 | - yarn test:coverage 15 | - yarn run coveralls 16 | -------------------------------------------------------------------------------- /src/components/text/text.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/extend-expect" 2 | import React from "react" 3 | 4 | import { render } from "../../utils/test-utils" 5 | import Text from "./text" 6 | 7 | test("expect small size when no size is passed", () => { 8 | const { getByTestId } = render(I'm a random text) 9 | 10 | getByTestId("text") 11 | }) 12 | -------------------------------------------------------------------------------- /src/components/app/app.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | 4 | import { App } from "./app" 5 | 6 | describe("App", function() { 7 | it("Should render without crashing", function() { 8 | const div = document.createElement("div") 9 | ReactDOM.render(, div) 10 | ReactDOM.unmountComponentAtNode(div) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme } from "styled-components" 2 | 3 | export const theme: DefaultTheme = { 4 | color: { 5 | baseBackground: "#091836", 6 | base: "#F3F3F3", 7 | special: "#D5A890", 8 | link: "#F33663" 9 | }, 10 | settings: { 11 | small: { size: "1.6rem", line_height: "1.92" }, 12 | medium: { size: "1.9rem", line_height: "2.28" }, 13 | large: { size: "2.2rem", line_height: "2.64" } 14 | }, 15 | breakpoints: { 16 | desktop: "45rem" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/styled.d.ts: -------------------------------------------------------------------------------- 1 | import "styled-components" 2 | 3 | interface TypoSetting { 4 | size: string 5 | line_height: string 6 | } 7 | 8 | declare module "styled-components" { 9 | export interface DefaultTheme { 10 | color: { 11 | baseBackground: string 12 | base: string 13 | special: string 14 | link: string 15 | } 16 | settings: { 17 | small: TypoSetting 18 | medium: TypoSetting 19 | large: TypoSetting 20 | [key: string]: TypoSetting 21 | } 22 | breakpoints: { 23 | desktop: string 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import { render, RenderResult } from "@testing-library/react" 2 | import React from "react" 3 | import { ThemeProvider } from "styled-components" 4 | 5 | import { theme } from "../styles/theme" 6 | 7 | const AllTheProviders: React.FunctionComponent = ({ children }) => { 8 | return {children} 9 | } 10 | 11 | const customRender: (ui: any, options?: any) => RenderResult = (ui, options) => 12 | render(ui, { wrapper: AllTheProviders, ...options }) 13 | 14 | // re-export everything 15 | export * from "@testing-library/react" 16 | 17 | // override render method 18 | export { customRender as render } 19 | -------------------------------------------------------------------------------- /src/components/app/app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styled, { ThemeProvider } from "styled-components" 3 | 4 | import { GlobalStyle } from "../../styles/global-style" 5 | import { theme } from "../../styles/theme" 6 | import { Notes } from "../notes/notes" 7 | 8 | const StyledContainer = styled.div(({ theme }) => { 9 | const { 10 | breakpoints: { desktop } 11 | } = theme 12 | 13 | return ` 14 | margin: 0 auto; 15 | max-width: 42rem; 16 | padding: 8rem 1.5rem; 17 | 18 | @media (min-width: ${desktop}) { 19 | padding: 8rem 3rem; 20 | } 21 | ` 22 | }) 23 | 24 | export const App: React.FC = () => { 25 | return ( 26 | <> 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/components/notes/hooks/use-notes.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { v4 } from "uuid" 3 | 4 | export interface Note { 5 | id?: string 6 | title: string 7 | } 8 | 9 | function useNotes() { 10 | const [notes, setNotes] = useState([]) 11 | 12 | const handleAddNote = function({ title }: Note) { 13 | setNotes(prevNotes => 14 | prevNotes.concat({ 15 | id: v4(), 16 | title 17 | }) 18 | ) 19 | } 20 | 21 | const handleUpdateNote = function(id: string | undefined, updates: Note) { 22 | setNotes(prevNotes => 23 | prevNotes.map(note => (note.id === id ? { ...note, ...updates } : note)) 24 | ) 25 | } 26 | 27 | const handleDeleteNote = function(id: string | undefined) { 28 | setNotes(prevNotes => prevNotes.filter(note => note.id !== id)) 29 | } 30 | 31 | return { 32 | notes, 33 | handleAddNote, 34 | handleUpdateNote, 35 | handleDeleteNote 36 | } 37 | } 38 | 39 | export { useNotes } 40 | -------------------------------------------------------------------------------- /src/components/text/text.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styled from "styled-components" 3 | 4 | type Size = "small" | "medium" | "large" 5 | 6 | interface Props { 7 | size?: Size 8 | className?: string 9 | children?: React.ReactNode 10 | } 11 | 12 | const StyledText = styled.p<{ selectedSize: Size }>( 13 | ({ theme, selectedSize }) => { 14 | const { 15 | settings: { 16 | [selectedSize]: { size, line_height } 17 | }, 18 | color: { special } 19 | } = theme 20 | 21 | return ` 22 | margin: 0; 23 | font-size: ${size}; 24 | line-height: ${line_height}; 25 | color: ${selectedSize === "large" ? special : "inherit"} 26 | ` 27 | } 28 | ) 29 | 30 | /** 31 | * Component responsible for rendering default text attributes and styles according to props 32 | */ 33 | const Text: React.FC = ({ size = "small", children, className }) => ( 34 | 35 | {children} 36 | 37 | ) 38 | 39 | export default Text 40 | -------------------------------------------------------------------------------- /src/styles/global-style.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from "styled-components" 2 | 3 | export const GlobalStyle = createGlobalStyle(res => { 4 | const { 5 | theme: { 6 | settings: { 7 | small: { size } 8 | }, 9 | color: { base, baseBackground } 10 | } 11 | } = res 12 | 13 | return ` 14 | *, 15 | *:before, 16 | *:after { 17 | box-sizing: inherit; 18 | } 19 | 20 | html, body { 21 | display: flex; 22 | min-height: 100%; 23 | } 24 | 25 | html { 26 | box-sizing: border-box; 27 | font-size: 62.5%; 28 | } 29 | 30 | body { 31 | margin: 0; 32 | padding: 0; 33 | font-family: Helvetica, Arial, sans-serif; 34 | flex: 1 100%; 35 | background: ${baseBackground}; 36 | color: ${base}; 37 | font-size: ${size}; 38 | line-height: ${size}; 39 | } 40 | 41 | #root { 42 | flex: 1 100%; 43 | } 44 | 45 | input { 46 | outline: none; 47 | background: none; 48 | border: none; 49 | padding: .6rem 0; 50 | text-indent: 1rem; 51 | color: inherit; 52 | font-size: inherit; 53 | transition: all .4s cubic-bezier(1, 0.35, 0, 0.93); 54 | 55 | &:focus { 56 | text-indent: 0; 57 | } 58 | } 59 | ` 60 | }) 61 | -------------------------------------------------------------------------------- /src/components/icons/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export const EditIcon: React.FC = () => ( 4 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | 28 | export const DeleteIcon: React.FC = () => ( 29 | 36 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ) 51 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fully-testable-react-hooks", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/react-hooks": "^3.2.1", 9 | "@testing-library/user-event": "^7.1.2", 10 | "@types/jest": "^24.0.0", 11 | "@types/node": "^12.0.0", 12 | "@types/react": "^16.9.0", 13 | "@types/react-dom": "^16.9.0", 14 | "@types/styled-components": "^5.0.1", 15 | "@types/uuid": "^7.0.0", 16 | "coveralls": "^3.0.9", 17 | "react": "^16.13.0", 18 | "react-dom": "^16.13.0", 19 | "react-scripts": "3.4.0", 20 | "react-test-renderer": "^16.13.0", 21 | "styled-components": "^5.0.1", 22 | "typescript": "~3.7.2", 23 | "uuid": "^7.0.2" 24 | }, 25 | "scripts": { 26 | "start": "react-scripts start", 27 | "build": "react-scripts build", 28 | "test": "react-scripts test", 29 | "eject": "react-scripts eject", 30 | "test:coverage": "yarn test --collectCoverage=true --watchAll=false", 31 | "coveralls": "yarn test:coverage --w && cat ./coverage/lcov.info | coveralls" 32 | }, 33 | "jest": { 34 | "collectCoverageFrom": [ 35 | "src/**/*.{ts,tsx}", 36 | "!src/index.tsx", 37 | "!src/*.d.ts" 38 | ] 39 | }, 40 | "eslintConfig": { 41 | "extends": "react-app" 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.2%", 46 | "not dead", 47 | "not op_mini all" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/notes/styles/index.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | import Text from "../../text/text" 4 | 5 | const StyledWrapper = styled.div` 6 | display: flex; 7 | flex-wrap: wrap; 8 | flex: 1 100%; 9 | ` 10 | 11 | const StyledInput = styled.input(({ theme }) => { 12 | const { 13 | color: { link }, 14 | settings: { medium, large } 15 | } = theme 16 | 17 | return ` 18 | margin: ${large.size} ${medium.size}; 19 | border-bottom: 1px solid ${link} 20 | ` 21 | }) 22 | 23 | const StyledTitle = styled(Text)(({ theme }) => { 24 | const { 25 | color: { special }, 26 | settings: { 27 | medium: { size } 28 | } 29 | } = theme 30 | return ` 31 | flex: 1 100%; 32 | border-bottom: 1px solid ${special}; 33 | text-indent: ${size} 34 | ` 35 | }) 36 | 37 | const StyledNotesList = styled.div` 38 | flex: 1 100%; 39 | ` 40 | 41 | const StyledNotesListItem = styled(Text)(({ theme }) => { 42 | const { 43 | color: { special }, 44 | settings: { 45 | medium: { size } 46 | } 47 | } = theme 48 | return ` 49 | display: flex; 50 | align-items: center; 51 | padding: .8rem 0; 52 | border-bottom: 1px solid ${special}; 53 | text-indent: ${size} 54 | ` 55 | }) 56 | 57 | const StyledListItemRightActions = styled.span` 58 | display: flex; 59 | margin-left: auto; 60 | ` 61 | 62 | const StyledActionItem = styled.span` 63 | display: flex; 64 | margin: 0 0.8rem; 65 | cursor: pointer; 66 | transition: opacity 0.4s linear; 67 | 68 | svg { 69 | vertical-align: middle; 70 | } 71 | 72 | &:hover { 73 | opacity: 0.8; 74 | } 75 | ` 76 | 77 | export { 78 | StyledWrapper, 79 | StyledInput, 80 | StyledTitle, 81 | StyledNotesList, 82 | StyledNotesListItem, 83 | StyledListItemRightActions, 84 | StyledActionItem 85 | } 86 | -------------------------------------------------------------------------------- /src/components/notes/notes.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import { useNotes } from "./hooks/use-notes" 4 | 5 | import { 6 | StyledWrapper, 7 | StyledInput, 8 | StyledTitle, 9 | StyledNotesList, 10 | StyledNotesListItem, 11 | StyledListItemRightActions, 12 | StyledActionItem 13 | } from "./styles" 14 | import { EditIcon, DeleteIcon } from "../icons" 15 | 16 | export const Notes: React.FC = () => { 17 | const { 18 | notes, 19 | handleAddNote, 20 | handleDeleteNote, 21 | handleUpdateNote 22 | } = useNotes() 23 | 24 | function handleFormSubmit(e: React.FormEvent) { 25 | e.preventDefault() 26 | const { 27 | currentTarget: { elements } 28 | } = e 29 | 30 | const titleElement = elements.namedItem("title") 31 | const value = (titleElement as HTMLInputElement).value 32 | 33 | handleAddNote({ title: value }) 34 | e.currentTarget.reset() 35 | } 36 | 37 | function handleDeleteButtonClick( 38 | e: React.MouseEvent 39 | ) { 40 | const { 41 | currentTarget: { 42 | dataset: { id } 43 | } 44 | } = e 45 | handleDeleteNote(id) 46 | } 47 | 48 | function handleEditButtonClick( 49 | e: React.MouseEvent 50 | ) { 51 | const { 52 | currentTarget: { 53 | dataset: { id } 54 | } 55 | } = e 56 | 57 | const title = "Nice edit ;)" 58 | 59 | handleUpdateNote(id, { title }) 60 | } 61 | 62 | return ( 63 | 64 | Notes 65 | 66 | 67 | {notes.map(({ id, title }) => ( 68 | 69 | {title} 70 | 71 | 72 | 77 | 78 | 79 | 84 | 85 | 86 | 87 | 88 | ))} 89 | 90 | 91 |
92 | 98 | 99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/components/notes/notes.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/extend-expect" 2 | import { render, fireEvent } from "../../utils/test-utils" 3 | import React from "react" 4 | 5 | import * as hooks from "./hooks/use-notes" 6 | 7 | import { Notes } from "./notes" 8 | 9 | describe("Notes", function() { 10 | const mockedNotes = [ 11 | { 12 | id: "11", 13 | title: "The world is a world" 14 | }, 15 | { 16 | id: "22", 17 | title: "This is a nice note" 18 | } 19 | ] 20 | 21 | it("Allows the user to add a note", function() { 22 | const HOOK_SPY: jest.SpyInstance = jest.spyOn( 23 | hooks, 24 | "useNotes" 25 | ) 26 | const ADD_NOTE_HANDLER: jest.Mock = jest.fn() 27 | 28 | /** Given There is 2 notes */ 29 | HOOK_SPY.mockReturnValue({ 30 | notes: mockedNotes, 31 | handleAddNote: ADD_NOTE_HANDLER 32 | }) 33 | 34 | /** And The notes form is rendered */ 35 | const { getByTestId } = render() 36 | const form: HTMLElement = getByTestId("notes-form") 37 | 38 | /** When The form is submitted */ 39 | fireEvent.submit(form) 40 | 41 | /** Then Expect that the add note handler has been called */ 42 | expect(ADD_NOTE_HANDLER).toHaveBeenCalled() 43 | }) 44 | 45 | /** 46 | * Feature: Read Notes 47 | */ 48 | it("Allows the user to read notes", function() { 49 | const HOOK_SPY: jest.SpyInstance = jest.spyOn( 50 | hooks, 51 | "useNotes" 52 | ) 53 | 54 | /** Given There is 2 notes */ 55 | HOOK_SPY.mockReturnValue({ 56 | notes: mockedNotes 57 | }) 58 | 59 | /** When The notes view is rendered */ 60 | const { getByText } = render() 61 | 62 | /** Then Expect to see the notes */ 63 | mockedNotes.forEach(function({ title }) { 64 | getByText(title) 65 | }) 66 | }) 67 | 68 | it("Allows the user to update notes", function() { 69 | const HOOK_SPY: jest.SpyInstance = jest.spyOn( 70 | hooks, 71 | "useNotes" 72 | ) 73 | const UPDATE_NOTE_HANDLER: jest.Mock = jest.fn() 74 | 75 | /** Given There is 2 notes */ 76 | HOOK_SPY.mockReturnValue({ 77 | notes: mockedNotes, 78 | handleUpdateNote: UPDATE_NOTE_HANDLER 79 | }) 80 | 81 | /** And The notes view is rendered */ 82 | const { getAllByTestId } = render() 83 | 84 | /** When An edit button is clicked */ 85 | const button = getAllByTestId("edit-note-button")[0] 86 | fireEvent.click(button) 87 | 88 | /** Then Expect the update note handler to be called */ 89 | expect(UPDATE_NOTE_HANDLER).toBeCalled() 90 | }) 91 | 92 | it("Allows the user to delete notes", function() { 93 | const HOOK_SPY: jest.SpyInstance = jest.spyOn( 94 | hooks, 95 | "useNotes" 96 | ) 97 | const DELETE_NOTE_HANDLER: jest.Mock = jest.fn() 98 | 99 | /** Given There is 2 notes */ 100 | HOOK_SPY.mockReturnValue({ 101 | notes: mockedNotes, 102 | handleDeleteNote: DELETE_NOTE_HANDLER 103 | }) 104 | 105 | /** And The notes view is rendered */ 106 | const { getAllByTestId } = render() 107 | 108 | /** When A delete button is clicked */ 109 | const button = getAllByTestId("delete-note-button")[0] 110 | fireEvent.click(button) 111 | 112 | /** Then Expect the delete note handler to be called */ 113 | expect(DELETE_NOTE_HANDLER).toBeCalled() 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fully-testable-react-hooks 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/edusorcerer/fully-testable-react-hooks/badge.svg)](https://coveralls.io/github/edusorcerer/fully-testable-react-hooks) [![Build Status](https://travis-ci.org/edusorcerer/fully-testable-react-hooks.svg?branch=master)](https://travis-ci.org/edusorcerer/fully-testable-react-hooks) 4 | 5 | Good practices implementation example for fully-testable hooks and view components on a React project 6 | 7 | **TL;DR** the essential implementation considerations on this example, from hooks to view components, are the following: 8 | 9 | - on a **view component implementation**, it must have no more than a **single entry point**. the entry point can be the component's own custom hook with multiple entry points 10 | - on a **view component test implementation**, it is the **assignment of mocked values to the single entry point(hook) return values** 11 | - on a **hook test implementation**, any **return value of the custom hook must have its own test block** 12 | 13 | --- 14 | 15 | ## implementation of testable view components 16 | 17 | - on a **view component** **implementation**, it must have at max a **single entry point**. - this results in less effort on the test implementation, specially because there will be only a single entry point to be mocked. - generally, to achieve this, **the view component can have its own custom hook**, making it the only entry point. the custom hook can then have as many entry points as necessary 18 | - example: [Notes component](https://github.com/edusorcerer/fully-testable-react-hooks/blob/master/src/components/notes/notes.tsx) 19 | - on a **view component test implementation**, the only consideration applied here is to the first step(assignment) of the feature steps(given -> when -> then). it is the **assignment of mocked values to the single entry point(hook) return values**. the next steps can be implemented with any usual techniques, as long as the component can be simulated with this assignment 20 | - example: [Notes component test](https://github.com/edusorcerer/fully-testable-react-hooks/blob/master/src/components/notes/notes.test.tsx) 21 | 22 | ## implementation of hook tests 23 | 24 | - on a **hook test implementation**, all the **return values of the custom hook must have its own `describe` block**, and the hook name itself serves just as a `describe` wrapper block 25 | - the primitive value(s) `describe` block(s) can be easily covered by validating the default value 26 | - inside a hook **event handler** `describe` block(s), there must be **one `it` block** **for each branching element** that exists on the source function logic(i.e. `if` conditions), for a properly test branching coverage 27 | 28 | - example: [useNotes hook test](https://github.com/edusorcerer/fully-testable-react-hooks/blob/master/src/components/notes/hooks/use-notes.test.tsx) 29 | 30 | ## installation 31 | 32 | - clone the repository 33 | - run `yarn && yarn start` 34 | 35 | ## features 36 | 37 | Behavior-driven development features of this project 38 | 39 | ```plaintext 40 | Feature: Create Note 41 | Given There is 0 notes 42 | When A note is created with the title "Learning technology is cool" 43 | Then Expect to have 1 note with title "Learning technology is cool" 44 | ``` 45 | 46 | ```plaintext 47 | Feature: Read Notes 48 | Given There is 2 notes 49 | When The notes listing is accessed 50 | Then Expect to see 2 notes 51 | ``` 52 | 53 | ```plaintext 54 | Feature: Update Note title 55 | Given There is a note with title "I bought fruit today" 56 | When The title of the note "I bought fruit today" is changed to "Buy fruit tomorrow" 57 | Then Expect to have a note with title "Buy fruit tomorrow" 58 | ``` 59 | 60 | ```plaintext 61 | Feature: Delete Note 62 | Given There is 2 notes 63 | When One note is deleted 64 | Then Expect to have only 1 note 65 | ``` 66 | -------------------------------------------------------------------------------- /src/components/notes/hooks/use-notes.test.tsx: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/extend-expect" 2 | import { renderHook, act } from "@testing-library/react-hooks" 3 | 4 | import { useNotes, Note } from "./use-notes" 5 | 6 | describe("useNotes", function() { 7 | describe("notes", function() { 8 | it("Should default to an empty array", function() { 9 | const { 10 | result: { 11 | current: { notes } 12 | } 13 | } = renderHook(() => useNotes()) 14 | expect(notes).toStrictEqual([]) 15 | }) 16 | }) 17 | 18 | /** 19 | * Feature: Create Note 20 | */ 21 | describe("handleAddNote", function() { 22 | it("Should add a note to the notes array", function() { 23 | /** Given There is 0 notes */ 24 | const { result } = renderHook(() => useNotes()) 25 | 26 | /** A note is created with the title "Learning technology is cool" */ 27 | const expectedTitle = "Learning technology is cool" 28 | act(() => result.current.handleAddNote({ title: expectedTitle })) 29 | 30 | /** Then Expect to have 1 note with title "Learning technology is cool" */ 31 | const createdNote = result.current.notes.find( 32 | ({ title }: Note) => title === expectedTitle 33 | ) 34 | expect(createdNote).toBeTruthy() 35 | }) 36 | }) 37 | 38 | /** 39 | * Feature: Update Note 40 | */ 41 | describe("handleUpdateNote", function() { 42 | it("Should update a note from the notes array", function() { 43 | const { result } = renderHook(() => useNotes()) 44 | 45 | /** Given There is a note with title "I bought fruit today" */ 46 | const fromTitle = "I bought fruit today" 47 | act(() => result.current.handleAddNote({ title: fromTitle })) 48 | 49 | /** When The title of the note "I bought fruit today" is changed to "Buy fruit tomorrow" */ 50 | const toTitle = "Buy fruit tomorrow" 51 | const noteToChange = result.current.notes.find( 52 | ({ title }: Note) => title === fromTitle 53 | ) 54 | 55 | act(() => 56 | result.current.handleUpdateNote(noteToChange?.id, { title: toTitle }) 57 | ) 58 | 59 | /** Then Expect to have a note with title "Buy fruit tomorrow" */ 60 | const expectedNote = result.current.notes.find( 61 | ({ title }: Note) => title === toTitle 62 | ) 63 | expect(expectedNote).toBeTruthy() 64 | }) 65 | 66 | /** 67 | * Another block for testing the handleUpdateNote function branching 68 | */ 69 | it("Should not update notes if there is no note with the given id", function() { 70 | const { result } = renderHook(() => useNotes()) 71 | 72 | /** Given There is a note with title "I bought fruit today" */ 73 | const fromTitle = "I bought fruit today" 74 | act(() => result.current.handleAddNote({ title: fromTitle })) 75 | 76 | /** When Try to update a note that doesn't exist */ 77 | const toTitle = "Buy fruit tomorrow" 78 | const noteToChange = result.current.notes.find( 79 | ({ title }: Note) => title === "This title doesn't exist" 80 | ) 81 | 82 | act(() => 83 | result.current.handleUpdateNote(noteToChange?.id, { title: toTitle }) 84 | ) 85 | 86 | /** Then Expect to see the same note */ 87 | const expectedNote = result.current.notes.find( 88 | ({ title }: Note) => title === fromTitle 89 | ) 90 | expect(expectedNote).toBeTruthy() 91 | }) 92 | }) 93 | 94 | /** 95 | * Feature: Delete Note 96 | */ 97 | describe("handleDeleteNote", function() { 98 | it("Should delete a note from the notes array", function() { 99 | const { result } = renderHook(() => useNotes()) 100 | 101 | /** Given There is 2 notes */ 102 | act(() => result.current.handleAddNote({ title: "Very nice Note" })) 103 | act(() => result.current.handleAddNote({ title: "Another Note" })) 104 | 105 | /** When One note is deleted */ 106 | const { id } = result.current.notes[0] 107 | act(() => result.current.handleDeleteNote(id)) 108 | 109 | /** Then Expect to have only 1 note */ 110 | expect(result.current.notes).toHaveLength(1) 111 | }) 112 | }) 113 | }) 114 | --------------------------------------------------------------------------------