diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f9d863b..401a51c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: Lint +name: Linting on: - push - pull_request diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5c97337 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: Tests +on: + - push + - pull_request +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [ 16.x ] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: npm install, build + run: npm ci + env: + CI: true + - name: run tests + run: npm run test:ci diff --git a/__tests__/404.test.tsx b/__tests__/404.test.tsx new file mode 100644 index 0000000..3c4a628 --- /dev/null +++ b/__tests__/404.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import * as Sentry from '@sentry/nextjs'; + +import NotFoundPage from '@/pages/404'; + +jest.mock('@sentry/nextjs'); + +describe('404 page', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders with correct heading', () => { + render(); + + const heading = screen.getByRole('heading', { + name: "YOU'RE IN THE WRONG PLACE", + }); + + expect(heading).toBeInTheDocument(); + }); + + test('send sentry error on render', () => { + render(); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('404')); + }); + + test('send sentry error only on initial render', () => { + render(); + + expect(Sentry.captureException).toBeCalledTimes(1); + }); +}); diff --git a/__tests__/blog.test.tsx b/__tests__/blog.test.tsx new file mode 100644 index 0000000..cfded0b --- /dev/null +++ b/__tests__/blog.test.tsx @@ -0,0 +1,75 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import BlogPage from '@/pages/blog'; +import { Post } from '@/lib/types'; + +jest.mock('@/lib/posts/api'); + +const posts: Post[] = [ + { + data: { + slug: 'first-article', + description: 'First article description', + featured: true, + readTime: '1', + tags: ['JS', 'React'], + title: 'First article title', + createDate: 11, + updateData: 12, + }, + content: 'First article long text', + }, + { + data: { + slug: 'second-article', + description: 'Second article description', + featured: false, + readTime: '2', + tags: ['TS', 'Redux'], + title: 'Second article title', + createDate: 124, + updateData: 123, + }, + content: 'Second article text', + }, +]; + +describe('Blog Page', () => { + test('renders with correct heading', () => { + render(); + const heading = screen.getByRole('heading', { + name: 'Blog', + }); + + expect(heading).toBeInTheDocument(); + }); + + test('renders with initial posts', () => { + render(); + const blogPostsLinks = screen.getAllByRole('link'); + + expect(blogPostsLinks).toHaveLength(posts.length); + }); + + test('on search correct filtered exist posts', () => { + render(); + const inputElement = screen.getByLabelText('Search articles'); + + fireEvent.change(inputElement, { target: { value: posts[0].data.title } }); + const post = screen.getByRole('heading', { + name: posts[0].data.title, + }); + + expect(post).toBeInTheDocument(); + }); + + test('on search render "No posts found" if unknown post', () => { + render(); + const inputElement = screen.getByLabelText('Search articles'); + + fireEvent.change(inputElement, { target: { value: 'Unknown post' } }); + const post = screen.getByText(/No posts found/); + + expect(post).toBeInTheDocument(); + }); +}); diff --git a/_posts/introducing-the-new-shramko.dev.md b/_posts/introducing-the-new-shramko.dev.md index 543fc6c..4318e7c 100644 --- a/_posts/introducing-the-new-shramko.dev.md +++ b/_posts/introducing-the-new-shramko.dev.md @@ -2,7 +2,7 @@ title: Introducing the new shramko.dev description: How I built a modern portfolio in 2022 createDate: 2022-08-13T13:31:25.041Z -updateData: 2022-08-14T08:22:34.069Z +updateData: 2022-08-28T19:37:43.285Z tags: [Website Redesign, Next.JS, React, Tailwind, Developer Portfolio, Portfolio, Website] featured: true --- @@ -27,19 +27,19 @@ T=0.11 s (779.3 files/s, 159045.1 lines/s) ------------------------------------------------------------------------------- Language files blank comment code ------------------------------------------------------------------------------- -JSON 6 0 0 14734 -TypeScript 55 182 11 1809 -XML 9 0 0 270 -Markdown 5 82 0 190 -JavaScript 3 6 2 178 +JSON 6 0 0 29012 +XML 14 0 0 7191 +TypeScript 63 240 14 2240 +Markdown 7 213 0 661 +JavaScript 5 10 6 201 CSS 2 35 1 165 +YAML 2 0 0 44 Bourne Shell 3 9 0 35 -YAML 1 0 0 22 SVG 1 0 0 17 Properties 1 0 0 4 Text 1 0 0 3 ------------------------------------------------------------------------------- -SUM: 87 314 14 17427 +SUM: 105 507 21 39573 ------------------------------------------------------------------------------- ``` @@ -68,6 +68,8 @@ Here are the primary technologies used in this project: any project you plan to maintain) - [Prisma](https://www.prisma.io): TypeScript ORM with Zero-Cost Type Safety - [SWR](https://swr.vercel.app/): Cool stale-while-revalidate hook +- [Jest](https://jestjs.io/): JavaScript Testing Framework +- [Testing Library](https://testing-library.com/): Simple and complete testing utilities - [Tailwind CSS](https://tailwindcss.com): Utility classes for consistent/maintainable styling - [Postcss](https://postcss.org/): CSS processor (pretty much just use it for @@ -235,6 +237,48 @@ const views = await prisma.views.upsert({ }); ``` +## Testing +As a developer, you know how important tests are for any production-level project. +Writing tests takes some time, but they will help you in the long run to solve problems in the codebase. + +I also [integrate these tests](https://github.com/Shramkoweb/Portfolio/blob/develop/.github/workflows/tests.yml) into GitHub Actions, so that whenever I deploy to production or make a pull request, tests will run automatically. + +My code coverage on 28 August: + +```shell +------------------------------|---------|----------|---------|---------|------------------- +File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s +------------------------------|---------|----------|---------|---------|------------------- +All files | 79.52 | 73.33 | 71.42 | 81.98 | + components/blog-post-preview | 100 | 50 | 100 | 100 | + blog-post-preview.tsx | 100 | 50 | 100 | 100 | 29 + index.ts | 100 | 100 | 100 | 100 | + components/footer | 100 | 100 | 100 | 100 | + get-copyright.ts | 100 | 100 | 100 | 100 | + components/post-reaction | 96.77 | 80 | 100 | 96.66 | + post-reaction.tsx | 100 | 100 | 100 | 100 | + use-feedback-reducer.ts | 93.33 | 66.66 | 100 | 92.85 | 18 + components/view-counter | 100 | 50 | 100 | 100 | + view-counter.tsx | 100 | 50 | 100 | 100 | 25 + lib | 100 | 100 | 100 | 100 | + fetcher.ts | 100 | 100 | 100 | 100 | + ga.ts | 100 | 100 | 100 | 100 | + types.ts | 100 | 100 | 100 | 100 | + lib/posts | 34.37 | 0 | 23.07 | 42.3 | + api.ts | 34.61 | 0 | 33.33 | 37.5 | 13-48,53-58,63-65 + utils.ts | 33.33 | 100 | 0 | 100 | + pages | 85.18 | 100 | 80 | 83.33 | + 404.tsx | 100 | 100 | 100 | 100 | + blog.tsx | 77.77 | 100 | 71.42 | 75 | 101-105 +------------------------------|---------|----------|---------|---------|------------------- + +Test Suites: 5 passed, 5 total +Tests: 13 passed, 13 total +Snapshots: 0 total +Time: 2.23 s +Ran all test suites. +``` + ## Next.js Next's framework allows you to build scalable, performant React code without the configuration hassle. diff --git a/components/footer/footer.tsx b/components/footer/footer.tsx index 3a0c899..94c9ffb 100644 --- a/components/footer/footer.tsx +++ b/components/footer/footer.tsx @@ -1,7 +1,9 @@ import Link from 'next/link'; +import { YEAR_OF_CREATE } from '@/lib/constants'; + import { FooterLink } from '@/components/footer-link'; -import { getCopyright } from '@/components/footer/get-copyright'; +import { getCopyrightYearString } from '@/components/footer/get-copyright'; export function Footer() { return ( @@ -10,9 +12,7 @@ export function Footer() {
- + Home @@ -29,20 +29,21 @@ export function Footer() {
GitHub - LinkedIn - Instagram + + LinkedIn + + + Instagram +
- My - Gear + My Gear - + Dashboard @@ -51,9 +52,10 @@ export function Footer() { © Made with ❤️ in {' '} - {getCopyright()} + {getCopyrightYearString(YEAR_OF_CREATE, new Date().getFullYear())} {' '} - by Serhii Shramko + by + Serhii Shramko ); diff --git a/components/footer/get-copyright.test.ts b/components/footer/get-copyright.test.ts new file mode 100644 index 0000000..3dc0aa2 --- /dev/null +++ b/components/footer/get-copyright.test.ts @@ -0,0 +1,21 @@ +import { getCopyrightYearString } from '@/components/footer/get-copyright'; + +describe('getCopyrightYearString', () => { + test('Return current year if now is 2022', () => { + const currentYear = 2022; + const createYear = 2022; + + const result = getCopyrightYearString(createYear, currentYear); + + expect(result).toStrictEqual('2022'); + }); + + test('Return string range between create and current', () => { + const currentYear = 2022; + const createYear = 2020; + + const result = getCopyrightYearString(createYear, currentYear); + + expect(result).toStrictEqual(`${createYear} - ${currentYear}`); + }); +}); diff --git a/components/footer/get-copyright.ts b/components/footer/get-copyright.ts index fc3832b..2170035 100644 --- a/components/footer/get-copyright.ts +++ b/components/footer/get-copyright.ts @@ -1,11 +1,10 @@ -const YEAR_OF_CREATE = 2022; - -export const getCopyright = () => { - const currentYear = new Date().getFullYear(); - - if (YEAR_OF_CREATE === currentYear) { +export const getCopyrightYearString = ( + createYear: number, + currentYear: number, +) => { + if (createYear === currentYear) { return currentYear.toString(); } - return `${YEAR_OF_CREATE} - ${currentYear}`; + return `${createYear} - ${currentYear}`; }; diff --git a/components/post-reaction/post-reaction.test.tsx b/components/post-reaction/post-reaction.test.tsx new file mode 100644 index 0000000..febec10 --- /dev/null +++ b/components/post-reaction/post-reaction.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import { PostReaction } from '@/components/post-reaction/post-reaction'; + +describe('PostReaction component', () => { + test('Render with initial state', () => { + render(); + + const title = screen.getByRole('heading', { + name: 'Was this article helpful ?', + }); + const description = screen.getByText('Help me improve my blog'); + + expect(title).toBeInTheDocument(); + expect(description).toBeInTheDocument(); + }); + + describe('reactions click:', () => { + test('worthless', () => { + window.gtag = jest.fn(); + render(); + + const worthlessReactionButton = screen.getByRole('button', { + name: 'No', + }); + fireEvent.click(worthlessReactionButton); + + expect(window.gtag).toHaveBeenCalledWith('event', 'Reaction click', { + event_category: 'Blog - article', + event_label: 'No', + value: undefined, + }); + }); + + test('helpful', () => { + window.gtag = jest.fn(); + render(); + + const worthlessReactionButton = screen.getByRole('button', { + name: 'Yes', + }); + fireEvent.click(worthlessReactionButton); + + expect(window.gtag).toHaveBeenCalledWith('event', 'Reaction click', { + event_category: 'Blog - article', + event_label: 'Yes', + value: undefined, + }); + }); + }); +}); diff --git a/components/view-counter/view-counter.test.tsx b/components/view-counter/view-counter.test.tsx new file mode 100644 index 0000000..54fe5d5 --- /dev/null +++ b/components/view-counter/view-counter.test.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react'; + +import { ViewCounter } from '@/components/view-counter/view-counter'; + +describe('ViewCounter component', () => { + beforeAll(() => { + global.fetch = jest.fn(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('Fetch views with SWC', () => { + const slug = 'test-article-slug'; + render(); + + // undefined because its get fetch from SWC + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + `/api/views/${slug}`, + undefined, + ); + expect(global.fetch).toHaveBeenNthCalledWith(2, `/api/views/${slug}`, { + method: 'POST', + }); + }); +}); diff --git a/components/view-counter/view-counter.tsx b/components/view-counter/view-counter.tsx index 05a1d7d..51cc74a 100644 --- a/components/view-counter/view-counter.tsx +++ b/components/view-counter/view-counter.tsx @@ -3,7 +3,6 @@ import useSWR from 'swr'; import { Views } from '@/lib/types'; import { fetcher } from '@/lib/fetcher'; -import { isProduction } from '@/lib/utils'; interface ViewCounterProps { slug: string; @@ -20,9 +19,7 @@ export function ViewCounter(props: ViewCounterProps) { method: 'POST', }); - if (isProduction()) { - registerView(); - } + registerView(); }, [slug]); return {`${views ? views.toLocaleString() : '---'} views`}; diff --git a/jest.config.js b/jest.config.js index 25e67c3..cd125cc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,7 @@ const customJestConfig = { moduleNameMapper: { '^@/components/(.*)$': '/components/$1', '^@/pages/(.*)$': '/pages/$1', + '^@/lib/(.*)$': '/lib/$1', }, testEnvironment: 'jest-environment-jsdom' }; diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 0000000..7193150 --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1 @@ +export const YEAR_OF_CREATE = 2022; diff --git a/package.json b/package.json index 70c6d42..6aa0844 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "prepare": "husky install", "start": "next start", "test": "jest --watch", - "test:ci": "jest --ci" + "test:ci": "jest --ci", + "test:coverage": "jest --coverage" }, "engines": { "node": ">=16" diff --git a/pages/404.tsx b/pages/404.tsx index 5feb6e7..0bb9e4d 100644 --- a/pages/404.tsx +++ b/pages/404.tsx @@ -5,6 +5,7 @@ import { useEffect } from 'react'; function NotFoundPage() { useEffect(() => { + // TODO refactor to some ErrorProvider Sentry.captureException(new Error('404')); }, []);