From 70c7f8652e5052cdab319bf2d63e26db11484836 Mon Sep 17 00:00:00 2001 From: Danang Massandy Date: Sun, 21 Sep 2025 22:02:02 +0000 Subject: [PATCH 1/2] add tests to components --- django_project/frontend/jest.setup.js | 6 + django_project/frontend/package.json | 2 +- .../components/ErrorBoundary/index.test.tsx | 232 ++++++++++++++++++ .../src/components/ErrorBoundary/index.tsx | 4 +- .../src/components/Maplibre/index.test.tsx | 193 +++++++++++++++ .../src/components/Maplibre/index.tsx | 4 +- .../src/components/NavBar/index.test.tsx | 72 ++++++ .../frontend/src/testing/render.tsx | 18 ++ 8 files changed, 526 insertions(+), 5 deletions(-) create mode 100644 django_project/frontend/src/components/ErrorBoundary/index.test.tsx create mode 100644 django_project/frontend/src/components/Maplibre/index.test.tsx create mode 100644 django_project/frontend/src/components/NavBar/index.test.tsx create mode 100644 django_project/frontend/src/testing/render.tsx diff --git a/django_project/frontend/jest.setup.js b/django_project/frontend/jest.setup.js index 4ccfd0e..df82d4c 100644 --- a/django_project/frontend/jest.setup.js +++ b/django_project/frontend/jest.setup.js @@ -1,3 +1,9 @@ if (typeof global.structuredClone === "undefined") { global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); +} + +if (typeof global.TextEncoder === 'undefined') { + const { TextEncoder, TextDecoder } = require('util'); + global.TextEncoder = TextEncoder; + global.TextDecoder = TextDecoder; } \ No newline at end of file diff --git a/django_project/frontend/package.json b/django_project/frontend/package.json index ef1e684..ca2afda 100644 --- a/django_project/frontend/package.json +++ b/django_project/frontend/package.json @@ -10,7 +10,6 @@ "@sentry/browser": "^10.7.0", "@sentry/react": "^10.7.0", "@sentry/tracing": "^7.120.4", - "@testing-library/user-event": "^14.6.1", "maplibre-gl": "^5.7.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -53,6 +52,7 @@ "@pmmmwh/react-refresh-webpack-plugin": "^0.6.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "@types/maplibre-gl": "^1.14.0", "@types/react": "^19.1.12", diff --git a/django_project/frontend/src/components/ErrorBoundary/index.test.tsx b/django_project/frontend/src/components/ErrorBoundary/index.test.tsx new file mode 100644 index 0000000..150287f --- /dev/null +++ b/django_project/frontend/src/components/ErrorBoundary/index.test.tsx @@ -0,0 +1,232 @@ +import React from 'react'; +import { render, screen, RenderResult } from '@testing-library/react'; +import ErrorBoundary from './index'; +import * as Sentry from '@sentry/react'; + +// Mock Sentry +jest.mock('@sentry/react', () => ({ + captureException: jest.fn(), +})); + +// Type the mocked Sentry +const mockedSentry = Sentry as jest.Mocked; + +// Component that throws an error for testing +interface ThrowErrorProps { + shouldThrow: boolean; +} + +const ThrowError: React.FC = ({ shouldThrow }) => { + if (shouldThrow) { + throw new Error('Test error message'); + } + return
No error
; +}; + +// Component that renders successfully +const SuccessComponent: React.FC = () =>
Success component
; + +describe('ErrorBoundary', () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + // Mock console.error to prevent error output during tests + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + // Clear Sentry mock calls + jest.clearAllMocks(); + }); + + afterEach(() => { + // Restore console.error + consoleErrorSpy.mockRestore(); + }); + + describe('when no error occurs', () => { + it('should render children normally', () => { + render( + + + + ); + + expect(screen.getByText('Success component')).toBeInTheDocument(); + }); + + it('should not call Sentry.captureException', () => { + render( + + + + ); + + expect(mockedSentry.captureException).not.toHaveBeenCalled(); + }); + }); + + describe('when an error occurs', () => { + it('should catch the error and render fallback UI', () => { + render( + + + + ); + + expect(screen.getByText('Something went wrong!')).toBeInTheDocument(); + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('should call Sentry.captureException with the error', () => { + render( + + + + ); + + expect(mockedSentry.captureException).toHaveBeenCalledTimes(1); + expect(mockedSentry.captureException).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Test error message' + }) + ); + }); + + it('should display the error message when error exists', () => { + render( + + + + ); + + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('should not display error message when error is null', () => { + // Create a custom error boundary instance to test null error state + interface TestErrorBoundaryProps { + children: React.ReactNode; + } + + interface TestErrorBoundaryState { + hasError: boolean; + error?: Error | null; + errorInfo?: React.ErrorInfo | null; + } + + class TestErrorBoundary extends ErrorBoundary { + constructor(props: TestErrorBoundaryProps) { + super(props); + // Manually set state to simulate null error + this.state = { + hasError: true, + error: null, + errorInfo: null + } as TestErrorBoundaryState; + } + } + + render( + +
Child component
+
+ ); + + expect(screen.getByText('Something went wrong!')).toBeInTheDocument(); + expect(screen.queryByText('Test error message')).not.toBeInTheDocument(); + }); + }); + + describe('getDerivedStateFromError', () => { + it('should return correct state when error occurs', () => { + const error = new Error('Test error'); + const result = ErrorBoundary.getDerivedStateFromError(error); + + expect(result).toEqual({ + hasError: true, + error: error, + errorInfo: null + }); + }); + }); + + describe('componentDidCatch', () => { + it('should update state with error and errorInfo', () => { + interface ErrorBoundaryProps { + children: React.ReactNode; + } + + const errorBoundaryInstance = new ErrorBoundary({} as ErrorBoundaryProps); + const error = new Error('Test error'); + const errorInfo: React.ErrorInfo = { + componentStack: 'Component stack trace' + }; + + // Spy on setState with proper typing + const setStateSpy = jest.spyOn(errorBoundaryInstance, 'setState') as jest.SpyInstance< + void, + [Partial<{ hasError: boolean; error?: Error | null; errorInfo?: React.ErrorInfo | null }>] + >; + + errorBoundaryInstance.componentDidCatch(error, errorInfo); + + expect(setStateSpy).toHaveBeenCalledWith({ + error: error, + errorInfo: errorInfo + }); + + expect(mockedSentry.captureException).toHaveBeenCalledWith(error); + }); + }); + + describe('error recovery', () => { + it('should render children normally after error is resolved', () => { + const { rerender }: RenderResult = render( + + + + ); + + // First render should show error boundary + expect(screen.getByText('Something went wrong!')).toBeInTheDocument(); + + // Re-render with non-throwing component + rerender( + + + + ); + + // Should still show error boundary (error boundaries don't auto-recover) + expect(screen.getByText('Something went wrong!')).toBeInTheDocument(); + }); + }); + + describe('multiple children', () => { + it('should render all children when no error occurs', () => { + render( + +
First child
+
Second child
+ +
+ ); + + expect(screen.getByText('First child')).toBeInTheDocument(); + expect(screen.getByText('Second child')).toBeInTheDocument(); + expect(screen.getByText('Success component')).toBeInTheDocument(); + }); + + it('should catch error from any child component', () => { + render( + +
First child
+ +
Third child
+
+ ); + + expect(screen.getByText('Something went wrong!')).toBeInTheDocument(); + expect(screen.queryByText('First child')).not.toBeInTheDocument(); + expect(screen.queryByText('Third child')).not.toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/django_project/frontend/src/components/ErrorBoundary/index.tsx b/django_project/frontend/src/components/ErrorBoundary/index.tsx index 0a2b0ea..7373bb3 100644 --- a/django_project/frontend/src/components/ErrorBoundary/index.tsx +++ b/django_project/frontend/src/components/ErrorBoundary/index.tsx @@ -7,8 +7,8 @@ interface Props { interface State { hasError: boolean; - error?: Error; - errorInfo?: ErrorInfo + error?: Error | null; + errorInfo?: ErrorInfo | null; } export default class ErrorBoundary extends React.Component { diff --git a/django_project/frontend/src/components/Maplibre/index.test.tsx b/django_project/frontend/src/components/Maplibre/index.test.tsx new file mode 100644 index 0000000..474d424 --- /dev/null +++ b/django_project/frontend/src/components/Maplibre/index.test.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import MapLibre from './index'; +import maplibregl from 'maplibre-gl'; +import {render } from "../../testing/render"; + +// Mock the CSS imports +jest.mock('maplibre-gl/dist/maplibre-gl.css', () => {}); +jest.mock('./style.scss', () => {}); + +// Mock the data imports +jest.mock('./data', () => ({ + layers: [ + { + id: 'test-layer', + type: 'background', + paint: { + 'background-color': '#ffffff' + } + } + ], + sources: { + 'test-source': { + type: 'vector', + url: 'test-url' + } + } +})); + +// Mock maplibre-gl +const mockAddControl = jest.fn(); +const mockMap = { + addControl: mockAddControl, + remove: jest.fn(), + getContainer: jest.fn(), + isStyleLoaded: jest.fn(() => true), + on: jest.fn(), + off: jest.fn(), +}; + +const mockNavigationControl = { + onAdd: jest.fn(), + onRemove: jest.fn(), +}; + +jest.mock('maplibre-gl', () => ({ + Map: jest.fn(() => mockMap), + NavigationControl: jest.fn(() => mockNavigationControl), +})); + +// Type the mocked maplibre-gl +const mockedMaplibregl = maplibregl as jest.Mocked; + +describe('MapLibre Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('rendering', () => { + it('should render a Box component with id "map"', () => { + render( + + ); + + const mapContainer = document.getElementById('map'); + expect(mapContainer).toBeInTheDocument(); + }); + + it('should render without crashing', () => { + expect(() => { + render( + + ); + }).not.toThrow(); + }); + }); + + describe('map initialization', () => { + it('should create a new maplibre-gl Map instance on mount', async () => { + render( + + ); + + await waitFor(() => { + expect(mockedMaplibregl.Map).toHaveBeenCalledTimes(1); + }); + }); + + it('should initialize map with correct configuration', async () => { + render( + + ); + + await waitFor(() => { + expect(mockedMaplibregl.Map).toHaveBeenCalledWith({ + container: 'map', + style: { + version: 8, + sources: { + 'test-source': { + type: 'vector', + url: 'test-url' + } + }, + layers: [ + { + id: 'test-layer', + type: 'background', + paint: { + 'background-color': '#ffffff' + } + } + ], + glyphs: '/static/fonts/{fontstack}/{range}.pbf', + }, + center: [0, 0], + zoom: 1, + attributionControl: false, + }); + }); + }); + + it('should add NavigationControl to the map', async () => { + render( + + ); + + await waitFor(() => { + expect(mockedMaplibregl.NavigationControl).toHaveBeenCalledTimes(1); + expect(mockAddControl).toHaveBeenCalledWith( + mockNavigationControl, + 'bottom-left' + ); + }); + }); + }); + + + describe('component cleanup', () => { + it('should handle component unmounting gracefully', () => { + const { unmount } = render( + + ); + + expect(() => { + unmount(); + }).not.toThrow(); + }); + }); + + describe('accessibility', () => { + it('should have proper container element for screen readers', () => { + render( + + ); + + const mapContainer = document.getElementById('map'); + expect(mapContainer).toBeInTheDocument(); + expect(mapContainer).toHaveAttribute('id', 'map'); + }); + }); + + describe('data integration', () => { + it('should use imported sources and layers in map style', async () => { + render( + + ); + + await waitFor(() => { + const mapCall = (mockedMaplibregl.Map as jest.Mock).mock.calls[0][0]; + expect(mapCall.style.sources).toEqual({ + 'test-source': { + type: 'vector', + url: 'test-url' + } + }); + expect(mapCall.style.layers).toEqual([ + { + id: 'test-layer', + type: 'background', + paint: { + 'background-color': '#ffffff' + } + } + ]); + }); + }); + }); +}); \ No newline at end of file diff --git a/django_project/frontend/src/components/Maplibre/index.tsx b/django_project/frontend/src/components/Maplibre/index.tsx index 7989b60..86e7a68 100644 --- a/django_project/frontend/src/components/Maplibre/index.tsx +++ b/django_project/frontend/src/components/Maplibre/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import maplibregl from "maplibre-gl"; import { Box } from "@chakra-ui/react"; @@ -9,7 +9,7 @@ import "./style.scss"; /** MapLibre component. */ export default function MapLibre() { - const [map, setMap] = useState(null); + const [map, setMap] = useState(null); /** Initiate */ useEffect(() => { diff --git a/django_project/frontend/src/components/NavBar/index.test.tsx b/django_project/frontend/src/components/NavBar/index.test.tsx new file mode 100644 index 0000000..fecaafa --- /dev/null +++ b/django_project/frontend/src/components/NavBar/index.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ChakraProvider } from '@chakra-ui/react'; +import '@testing-library/jest-dom'; +import {render } from "../../testing/render"; +import Navbar from './index'; // Adjust path as needed + +// Mock console.error to avoid error logs during testing +const originalConsoleError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); + +afterAll(() => { + console.error = originalConsoleError; +}); + +// Helper function to render component with providers +const renderNavbar = () => { + return render( + + ); +}; + +describe('Navbar Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + test('renders navbar with correct structure', () => { + renderNavbar(); + + // Check if main header element exists + const header = screen.getByRole('banner'); + expect(header).toBeInTheDocument(); + expect(header).toHaveStyle({ background: 'var(--chakra-colors-primary-main)' }); + }); + + test('renders heading with correct text and link', () => { + renderNavbar(); + + const heading = screen.getByRole('heading', { name: /kartoza django react base/i }); + expect(heading).toBeInTheDocument(); + + const homeLink = screen.getByRole('link', { name: /kartoza django react base/i }); + expect(homeLink).toBeInTheDocument(); + expect(homeLink).toHaveAttribute('href', '/'); + }); + + test('renders all navigation links', () => { + renderNavbar(); + + const mapLink = screen.getByRole('link', { name: /map/i }); + const aboutLink = screen.getByRole('link', { name: /about/i }); + + expect(mapLink).toBeInTheDocument(); + expect(mapLink).toHaveAttribute('href', '/map'); + + expect(aboutLink).toBeInTheDocument(); + expect(aboutLink).toHaveAttribute('href', '/about'); + }); + + test('renders login button', () => { + renderNavbar(); + + const loginButton = screen.getByRole('button', { name: /login/i }); + expect(loginButton).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/django_project/frontend/src/testing/render.tsx b/django_project/frontend/src/testing/render.tsx new file mode 100644 index 0000000..fb33953 --- /dev/null +++ b/django_project/frontend/src/testing/render.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { ChakraProvider } from '@chakra-ui/react'; +import { BrowserRouter } from 'react-router-dom'; +import { render as rtlRender } from "@testing-library/react" +import { kartozaTheme } from "../theme"; + + +export function render(ui: React.ReactNode) { + return rtlRender(<>{ui}, { + wrapper: (props: React.PropsWithChildren) => ( + + + {props.children} + + + ), + }) +} \ No newline at end of file From 22d63ed40687fe59b06f661fb9578a90d4deeee5 Mon Sep 17 00:00:00 2001 From: Danang Massandy Date: Sun, 21 Sep 2025 22:04:55 +0000 Subject: [PATCH 2/2] add vscode-jest extension --- .devcontainer/devcontainer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6b5676c..99b147b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,7 +25,8 @@ "ms-python.python", "ms-azuretools.vscode-docker", "ms-python.flake8", - "njpwerner.autodocstring" + "njpwerner.autodocstring", + "Orta.vscode-jest" ], "settings": { "terminal.integrated.shell.linux": "/bin/bash",