From 8a6a6712e7554f110b5ef951f270d88fd010e040 Mon Sep 17 00:00:00 2001 From: Bertrand Yuan Date: Thu, 26 Mar 2026 00:02:16 +0800 Subject: add more tests --- src/components/active-link.test.tsx | 57 ++++++++++++++++++++++++ src/components/inline-link.test.tsx | 41 +++++++++++++++++ src/components/json-ld.test.tsx | 66 ++++++++++++++++++++++++++++ src/components/numbered-pagination.test.tsx | 64 +++++++++++++++++++++++++++ src/components/rich-text/node-format.test.ts | 40 +++++++++++++++++ src/components/section.test.tsx | 19 ++++++++ src/components/tags/tag-card.test.tsx | 36 +++++++++++++++ src/components/tailwind-indicator.test.tsx | 30 +++++++++++++ src/components/theme-provider.test.tsx | 23 ++++++++++ src/components/ui/alert.test.tsx | 30 +++++++++++++ src/components/ui/button-variants.test.ts | 28 ++++++++++++ src/components/ui/card.test.tsx | 40 +++++++++++++++++ src/components/ui/input.test.tsx | 22 ++++++++++ src/components/ui/label.test.tsx | 15 +++++++ src/components/ui/pagination.test.tsx | 61 +++++++++++++++++++++++++ src/components/ui/skeleton.test.tsx | 16 +++++++ 16 files changed, 588 insertions(+) create mode 100644 src/components/active-link.test.tsx create mode 100644 src/components/inline-link.test.tsx create mode 100644 src/components/json-ld.test.tsx create mode 100644 src/components/numbered-pagination.test.tsx create mode 100644 src/components/rich-text/node-format.test.ts create mode 100644 src/components/section.test.tsx create mode 100644 src/components/tags/tag-card.test.tsx create mode 100644 src/components/tailwind-indicator.test.tsx create mode 100644 src/components/theme-provider.test.tsx create mode 100644 src/components/ui/alert.test.tsx create mode 100644 src/components/ui/button-variants.test.ts create mode 100644 src/components/ui/card.test.tsx create mode 100644 src/components/ui/input.test.tsx create mode 100644 src/components/ui/label.test.tsx create mode 100644 src/components/ui/pagination.test.tsx create mode 100644 src/components/ui/skeleton.test.tsx (limited to 'src/components') diff --git a/src/components/active-link.test.tsx b/src/components/active-link.test.tsx new file mode 100644 index 0000000..0c603cb --- /dev/null +++ b/src/components/active-link.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { ActiveLink } from './active-link'; +import { usePathname } from 'next/navigation'; + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(), +})); + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + }) => ( + + {children} + + ), +})); + +describe('ActiveLink', () => { + test('applies active styles when pathname matches href', () => { + vi.mocked(usePathname).mockReturnValue('/posts'); + + render(Posts); + + const link = screen.getByRole('link', { name: 'Posts' }); + expect(link).toHaveClass('font-medium'); + }); + + test('does not apply active styles for non-matching paths', () => { + vi.mocked(usePathname).mockReturnValue('/tags'); + + render(Posts); + + const link = screen.getByRole('link', { name: 'Posts' }); + expect(link).not.toHaveClass('font-medium'); + }); + + test('supports nested path matching when nested prop is enabled', () => { + vi.mocked(usePathname).mockReturnValue('/posts/testing-vitest'); + + render( + + Posts + , + ); + + const link = screen.getByRole('link', { name: 'Posts' }); + expect(link).toHaveClass('font-medium'); + }); +}); diff --git a/src/components/inline-link.test.tsx b/src/components/inline-link.test.tsx new file mode 100644 index 0000000..ad9813d --- /dev/null +++ b/src/components/inline-link.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { InlineLink } from './inline-link'; + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + }) => ( + + {children} + + ), +})); + +describe('InlineLink', () => { + test('renders as a normal link by default', () => { + render(Posts); + + const link = screen.getByRole('link', { name: 'Posts' }); + expect(link).toHaveAttribute('href', '/posts'); + expect(link).not.toHaveAttribute('target', '_blank'); + }); + + test('sets target blank when blank prop is true', () => { + render( + + External + , + ); + + const link = screen.getByRole('link', { name: 'External' }); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('target', '_blank'); + }); +}); diff --git a/src/components/json-ld.test.tsx b/src/components/json-ld.test.tsx new file mode 100644 index 0000000..c96e5c3 --- /dev/null +++ b/src/components/json-ld.test.tsx @@ -0,0 +1,66 @@ +import { render } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('@/app/(main)/layout.config', () => ({ + title: 'Blog', + owner: 'Test Owner', +})); + +vi.mock('@/lib/constants', () => ({ + baseUrl: new URL('http://localhost:3000'), +})); + +import { PostJsonLd, TagJsonLd } from './json-ld'; + +describe('json-ld components', () => { + test('renders post json-ld with blog posting and breadcrumb graph', () => { + const { container } = render( + , + ); + + const script = container.querySelector('script[type="application/ld+json"]'); + expect(script).toBeInTheDocument(); + + const payload = JSON.parse(script?.innerHTML ?? '{}'); + expect(payload['@context']).toBe('https://schema.org'); + expect(payload['@graph']).toHaveLength(2); + expect(payload['@graph'][0]['@type']).toBe('BlogPosting'); + expect(payload['@graph'][0].mainEntityOfPage['@id']).toBe( + 'http://localhost:3000/posts/testing-post', + ); + }); + + test('returns null when post is not provided', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('renders tag json-ld with tag-specific breadcrumb item', () => { + const { container } = render(); + + const script = container.querySelector('script[type="application/ld+json"]'); + expect(script).toBeInTheDocument(); + + const payload = JSON.parse(script?.innerHTML ?? '{}'); + expect(payload['@graph']).toHaveLength(1); + expect(payload['@graph'][0]['@type']).toBe('BreadcrumbList'); + expect(payload['@graph'][0].itemListElement[2].item).toBe( + 'http://localhost:3000/tags/nextjs', + ); + }); +}); diff --git a/src/components/numbered-pagination.test.tsx b/src/components/numbered-pagination.test.tsx new file mode 100644 index 0000000..d99ab46 --- /dev/null +++ b/src/components/numbered-pagination.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { NumberedPagination } from './numbered-pagination'; + +describe('NumberedPagination', () => { + test('renders page controls and current page', () => { + render( + , + ); + + expect(screen.getByRole('link', { name: 'Go to previous page' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '5' })).toHaveAttribute('aria-current'); + expect(screen.getAllByText('...')).toHaveLength(2); + }); + + test('calls onPageChange when selecting a valid page', () => { + const onPageChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('link', { name: '3' })); + expect(onPageChange).toHaveBeenCalledWith(3); + }); + + test('does not call onPageChange for out-of-range previous/next actions', () => { + const onPageChange = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole('link', { name: 'Go to previous page' })); + expect(onPageChange).not.toHaveBeenCalled(); + + rerender( + , + ); + + fireEvent.click(screen.getByRole('link', { name: 'Go to next page' })); + expect(onPageChange).not.toHaveBeenCalled(); + }); +}); + diff --git a/src/components/rich-text/node-format.test.ts b/src/components/rich-text/node-format.test.ts new file mode 100644 index 0000000..476a0d9 --- /dev/null +++ b/src/components/rich-text/node-format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; +import { + IS_BOLD, + IS_CODE, + IS_HIGHLIGHT, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_UNDERLINE, +} from './node-format'; + +describe('node format bit flags', () => { + test('uses power-of-two values for unique bitmasks', () => { + const flags = [ + IS_BOLD, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_UNDERLINE, + IS_CODE, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_HIGHLIGHT, + ]; + + for (const flag of flags) { + expect((flag & (flag - 1)) === 0).toBe(true); + } + }); + + test('can combine and detect individual flags with bitwise operations', () => { + const format = IS_BOLD | IS_ITALIC | IS_CODE; + + expect((format & IS_BOLD) !== 0).toBe(true); + expect((format & IS_ITALIC) !== 0).toBe(true); + expect((format & IS_CODE) !== 0).toBe(true); + expect((format & IS_UNDERLINE) !== 0).toBe(false); + }); +}); + diff --git a/src/components/section.test.tsx b/src/components/section.test.tsx new file mode 100644 index 0000000..18002ae --- /dev/null +++ b/src/components/section.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Section } from './section'; + +describe('Section', () => { + test('renders children and forwards section attributes', () => { + render( +
+
Body
+
, + ); + + const section = screen.getByTestId('section'); + expect(section.tagName).toBe('SECTION'); + expect(section.className).toContain('py-20'); + expect(screen.getByText('Body')).toBeInTheDocument(); + }); +}); + diff --git a/src/components/tags/tag-card.test.tsx b/src/components/tags/tag-card.test.tsx new file mode 100644 index 0000000..645441b --- /dev/null +++ b/src/components/tags/tag-card.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { TagCard } from './tag-card'; + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...props + }: { + href: string; + children: ReactNode; + }) => ( + + {children} + + ), +})); + +describe('TagCard', () => { + test('renders a tag link with computed tag url', () => { + render(); + + const link = screen.getByRole('link', { name: /react/i }); + expect(link).toHaveAttribute('href', '/tags/react'); + }); + + test('renders count only when displayCount is true and count exists', () => { + const { rerender } = render(); + expect(screen.getByText('(12)')).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText('(12)')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/tailwind-indicator.test.tsx b/src/components/tailwind-indicator.test.tsx new file mode 100644 index 0000000..4244ef6 --- /dev/null +++ b/src/components/tailwind-indicator.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { afterAll, beforeEach, describe, expect, test } from 'vitest'; +import { TailwindIndicator } from './tailwind-indicator'; + +const originalEnv = process.env; + +describe('TailwindIndicator', () => { + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + test('renders in non-production environments', () => { + process.env.NODE_ENV = 'test'; + render(); + + expect(screen.getByText('xs')).toBeInTheDocument(); + }); + + test('returns null in production', () => { + process.env.NODE_ENV = 'production'; + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); +}); + diff --git a/src/components/theme-provider.test.tsx b/src/components/theme-provider.test.tsx new file mode 100644 index 0000000..0ac54e1 --- /dev/null +++ b/src/components/theme-provider.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, test, vi } from 'vitest'; +import { ThemeProvider } from './theme-provider'; + +vi.mock('next-themes', () => ({ + ThemeProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +describe('ThemeProvider', () => { + test('renders children through next-themes provider', () => { + render( + + content + , + ); + + expect(screen.getByTestId('next-themes-provider')).toBeInTheDocument(); + expect(screen.getByText('content')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/alert.test.tsx b/src/components/ui/alert.test.tsx new file mode 100644 index 0000000..ded0356 --- /dev/null +++ b/src/components/ui/alert.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Alert, AlertDescription, AlertTitle } from './alert'; + +describe('Alert', () => { + test('renders with role and slot attributes', () => { + render( + + Heads up + Something happened. + , + ); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveAttribute('data-slot', 'alert'); + expect(screen.getByText('Heads up')).toHaveAttribute('data-slot', 'alert-title'); + expect(screen.getByText('Something happened.')).toHaveAttribute( + 'data-slot', + 'alert-description', + ); + }); + + test('applies destructive variant classes', () => { + render(Danger); + + const alert = screen.getByRole('alert'); + expect(alert.className).toContain('text-destructive'); + }); +}); + diff --git a/src/components/ui/button-variants.test.ts b/src/components/ui/button-variants.test.ts new file mode 100644 index 0000000..5090703 --- /dev/null +++ b/src/components/ui/button-variants.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'vitest'; +import { buttonVariants } from './button'; + +describe('buttonVariants', () => { + test('returns default variant and size classes when no args are provided', () => { + const classes = buttonVariants(); + + expect(classes).toContain('bg-primary'); + expect(classes).toContain('h-9'); + }); + + test('returns destructive and icon classes for explicit options', () => { + const classes = buttonVariants({ + variant: 'destructive', + size: 'icon', + }); + + expect(classes).toContain('bg-destructive'); + expect(classes).toContain('size-9'); + }); + + test('includes caller-provided className', () => { + const classes = buttonVariants({ className: 'w-full' }); + + expect(classes).toContain('w-full'); + }); +}); + diff --git a/src/components/ui/card.test.tsx b/src/components/ui/card.test.tsx new file mode 100644 index 0000000..aba3d00 --- /dev/null +++ b/src/components/ui/card.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from './card'; + +describe('Card', () => { + test('renders all card slots', () => { + render( + + + Card Title + Card Description + Action + + Content + Footer + , + ); + + expect(screen.getByText('Card Title')).toHaveAttribute( + 'data-slot', + 'card-title', + ); + expect(screen.getByText('Card Description')).toHaveAttribute( + 'data-slot', + 'card-description', + ); + expect(screen.getByText('Action')).toHaveAttribute('data-slot', 'card-action'); + expect(screen.getByText('Content')).toHaveAttribute('data-slot', 'card-content'); + expect(screen.getByText('Footer')).toHaveAttribute('data-slot', 'card-footer'); + }); +}); + diff --git a/src/components/ui/input.test.tsx b/src/components/ui/input.test.tsx new file mode 100644 index 0000000..aabe76f --- /dev/null +++ b/src/components/ui/input.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Input } from './input'; + +describe('Input', () => { + test('renders an input with the expected slot attribute', () => { + render(); + + const input = screen.getByLabelText('Email'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('data-slot', 'input'); + }); + + test('supports input type and disabled props', () => { + render(); + + const input = screen.getByLabelText('Password'); + expect(input).toHaveAttribute('type', 'password'); + expect(input).toBeDisabled(); + }); +}); + diff --git a/src/components/ui/label.test.tsx b/src/components/ui/label.test.tsx new file mode 100644 index 0000000..08031b6 --- /dev/null +++ b/src/components/ui/label.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Label } from './label'; + +describe('Label', () => { + test('renders label text and exposes data-slot', () => { + render(); + + const label = screen.getByText('Email'); + expect(label).toBeInTheDocument(); + expect(label).toHaveAttribute('for', 'email'); + expect(label).toHaveAttribute('data-slot', 'label'); + }); +}); + diff --git a/src/components/ui/pagination.test.tsx b/src/components/ui/pagination.test.tsx new file mode 100644 index 0000000..77309b8 --- /dev/null +++ b/src/components/ui/pagination.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from './pagination'; + +describe('Pagination UI', () => { + test('renders pagination container and list slots', () => { + render( + + + + 1 + + + , + ); + + const nav = screen.getByLabelText('pagination'); + expect(nav).toHaveAttribute('data-slot', 'pagination'); + expect(screen.getByRole('link', { name: '1' })).toHaveAttribute( + 'data-slot', + 'pagination-link', + ); + }); + + test('sets active page attributes', () => { + render( + + 2 + , + ); + + const link = screen.getByRole('link', { name: '2' }); + expect(link).toHaveAttribute('aria-current', 'page'); + expect(link).toHaveAttribute('data-active', 'true'); + }); + + test('renders previous, next, and ellipsis affordances', () => { + render( +
+ + + +
, + ); + + expect( + screen.getByRole('link', { name: 'Go to previous page' }), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Go to next page' })).toBeInTheDocument(); + expect(screen.getByText('More pages')).toBeInTheDocument(); + }); +}); + diff --git a/src/components/ui/skeleton.test.tsx b/src/components/ui/skeleton.test.tsx new file mode 100644 index 0000000..4459d77 --- /dev/null +++ b/src/components/ui/skeleton.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test } from 'vitest'; +import { Skeleton } from './skeleton'; + +describe('Skeleton', () => { + test('renders with default and custom classes', () => { + render(); + + const skeleton = screen.getByTestId('skeleton'); + expect(skeleton).toHaveAttribute('data-slot', 'skeleton'); + expect(skeleton.className).toContain('animate-pulse'); + expect(skeleton.className).toContain('h-4'); + expect(skeleton.className).toContain('w-20'); + }); +}); + -- cgit v1.2.3