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'));
}, []);