├── 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 |
26 | )
27 |
28 | export const DeleteIcon: React.FC = () => (
29 |
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 |
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 | [](https://coveralls.io/github/edusorcerer/fully-testable-react-hooks) [](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 |
--------------------------------------------------------------------------------