Process of applying TDD to a NextJS project
Introduction
Test-Driven Development (TDD) is an advanced software development methodology where tests are written before the actual source code is implemented. This process operates in a repetitive cycle: Red (Write a failing test) -> Green (Write the minimum source code to make the test pass) -> Refactor (Optimize the code structure).
Advantages:
- Improve source code quality: Minimize potential bugs right from the initial development phase.
- Better system design: Thinking about testing first helps you build highly modular, loosely coupled and easy-to-maintain modules.
- Confident Refactoring: You can comfortably improve and optimize code without fear of breaking existing features, thanks to the automated test system protection.
- Living documentation: Test cases act as precise specification documentation, helping team members clearly understand how the system operates.
- Reduce Debugging Time: TDD helps detect errors right at the moment "just finished typing". The cost of fixing a bug right after writing is many times cheaper than fixing it when it has reached the Staging or Production environment.
QnA
The following are some issues you might wonder about when applying TDD and the corresponding solutions
When should you use TDD?
Handling complex core business logic
- If you are writing a tax or insurance premium calculation module, or an e-commerce pricing engine with dozens of nested conditions, coding everything and then testing will make it easy to miss edge cases.
- TDD allows you to list all cases (Input A -> Output B) as test cases, then fill in the code. It ensures every corner of the logic is verified.
Refactoring sensitive legacy code
- When you take over an important module but the code is too messy (Spaghetti code) and needs to be converted to a new architecture (for example, from Class-based to Hooks, or from Monolith to Microservices).
- At this point, you will need to write tests for the inputs and outputs of the old module to lock its behavior. Then you rewrite the new code completely. If the old test suite still passes, you have successfully refactored without changing features.
Building Open Source Libraries or Shared Packages
- If you are building shared libraries for the team (such as a UI Kit, a set of Utils, or a wrapper for an API).
- Because many people or teams share the library, applying TDD helps shape the API Design extremely well. Because you have to build tests first, which means following the Consumer-First direction before implementing details, this will help you check in advance whether the provided interface is easy to use before wasting effort implementing the details inside, helping optimize Developer Experience (DX) from the very first line of code.
When should you not use TDD?
Although very powerful, TDD was not born for every project, so please consider it for the following cases
Making Prototype/POC (Proof of Concept): When this is just a small project for experimental purposes with requirements constantly changing, writing tests first will slow down the deployment speed.
UI/UX is too complex: Testing CSS, animations, or drag-and-drop interactions with TDD usually takes more effort than the effectiveness it brings. With UI testing, sometimes applying manual or visual tests is more effective.
- UI Appearance: Applying TDD purely to UI can lead to the following issues
- Fragility: Just changing a CSS class or changing from a
<div>tag to a<section>tag can make the test fail, even though the functionality remains unchanged. - High maintenance cost: Interface is the thing that changes the most based on feedback from the Designer/Product Owner. Writing tests first for every pixel will make you lose more time to fix tests every time you change colors for components.
- Difficult to simulate with code: It is very difficult to write a test to describe CSS effects like animation/transition, box-shadow precisely.
- UI Logic: Can be used to test the following components
- Custom Hooks: To write tests for pure UI logic (like useCart, useAuth)
- Utilities & Helpers: Currency formatting functions, time processing.
- API Routes & Server Actions: This is an important logic part related to integration.
- In addition, it can also be applied to test Conditional Rendering, Form Validation, Data Mapping.
Level of application into the project?
In a real project environment, applying TDD 100% to every corner often leads to burnout. You should make a choice and allocate according to the 80/20 rule flexibly as follows:
Prioritize TDD for (High ROI):
- Core Business Logic: Things related to money, user data, security (e.g., payment, salary calculation, authorization).
- Shared functions (Utilities): If the date format function fails, the whole system will fail. TDD here is mandatory.
- Old bugs: Whenever there is a bug, write a test simulating that bug first, then fix the code (this is called Regression TDD).
Can skip TDD for (Low ROI):
- UI/UX/Styling: Testing the color of a component with TDD takes a lot of effort but brings little benefit.
- Simple third-party integration: If you use a third-party library that has been thoroughly tested, you do not necessarily have to TDD that library again.
- Configuration (Config): The .env files, config files do not need TDD.
Does TDD require any specific type of test?
There is no specific requirement for a type of test when deploying TDD, but usually, to apply TDD most effectively, you can start from Integration Test, then drill down to Unit Test for complex logic parts.
Should you write each test case one by one or multiple test cases at the same time?
Uncle Bob Martin authored the Three Rules of TDD, which is enough to get started with TDD:
- You are not allowed to write any production code unless it is to make a failing unit test pass.
- You are not allowed to write any more of a unit test than is sufficient to fail, and compilation failures are failures.
- You are not allowed to write any more production code than is sufficient to pass the one failing unit test.
- This means you should write each test one by one, which will help you focus absolutely. When you have to write 5-10 test cases at the same time, you will create multiple issues simultaneously and get easily confused when implementing code to satisfy all of them.
- The standard process will be as follows: Write test case 1 -> Code for Pass -> Write test case 2 -> Code for Pass -> Continue with each subsequent case.
Effectiveness of TDD?
Based on the document Realizing quality improvement through test driven development: results and experiences of four industrial teams, this is the largest-scale study on TDD conducted among development teams at Microsoft and IBM.
- Quality Results: The defect density of teams using TDD (Test-Driven Development) is 40% to 90% lower than teams using TLD (Test-Last Development).
- Performance Results (Time/Effort): The development time of TDD teams increases by about 15% to 35%.
- This means you will have to spend about 20% more time initially to write tests, but in return, you reduce almost double the number of bugs on Production.
- This study indicates that TDD helps code have higher Modularity because programmers are forced to write easy-to-test code. This indirectly reduces maintenance costs in the long term.
Thus, if applied correctly, TDD will bring outstanding efficiency in the long run compared to TLD, specifically as follows:
TDD
- Bug Density: Low (due to covering all edge cases from the beginning).
- Initial Dev Time: Slower.
- Maintenance Cost: Low (clean code, easy to refactor).
- Design Quality: Good (Low coupling, High cohesion).
- Dev Psychology: Confident, less pressure when deploying.
TLD
- Bug Density: Higher (easy to miss sub-cases due to lack of necessary tests).
- Initial Dev Time: Faster (because focusing on output immediately).
- Maintenance Cost: High (hesitant to fix for fear of breaking parts without tests).
- Design Quality: Tight coupling.
- Dev Psychology: Anxious, spending a lot of time debugging at the end of the sprint.
Prerequisites
- In this article, I will focus on the TDD process to deploy tests, so I will not talk in detail about how to set up testing environments, here I use NextJS, Vitest and MSW for integration testing, you can look through previous articles to know the necessary setup steps or use similar frameworks or tools.
- I use Vitest because its superior performance will help you have an extremely fast feedback loop so that when writing tests and implementing, you will see results quickly.
Detail
First, let's create the file test/Login/integration.test.tsx to write a test for the first feature, which is a successful login.
import LoginPage from '@/app/feature/login/page'
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import {message} from 'antd'
import {http, HttpResponse} from 'msw'
import {setupServer} from 'msw/node'
import {useRouter} from 'next/navigation'
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest'
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
}))
const server = setupServer(
http.post('/api/login', () => {
return HttpResponse.json({success: true}, {status: 200})
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
afterEach(() => cleanup())
vi.mock('antd', async importOriginal => {
const actual: any = await importOriginal()
return {
...actual,
message: {
success: vi.fn(),
error: vi.fn(),
},
}
})
describe('Login Functionality', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect to home on successful login', async () => {
const push = vi.fn()
vi.mocked(useRouter).mockReturnValue({push} as any)
render(<LoginPage />)
fireEvent.change(screen.getByTestId('login-email-input'), {
target: {value: 'test@example.com'},
})
fireEvent.change(screen.getByTestId('login-password-input'), {
target: {value: 'password123'},
})
fireEvent.click(screen.getByTestId('login-submit-button'))
await waitFor(() => {
expect(push).toHaveBeenCalledWith('/home')
})
})
})
The above code sets up an integration test using Vitest, React Testing Library and MSW for the login feature. Specifically:
- Uses
vi.mockto simulate (mock) theuseRouterhook from thenext/navigationlibrary and themessagecomponent fromantd. - Initializes a mock server with MSW (
setupServer) to intercept thePOST /api/loginrequest to always return a 200 status and a successful result. - Manages the lifecycle of the mock server through
beforeAll,afterEach,afterAllfunctions and cleans up the DOM withcleanup. - In test case
CASE 1, the code rendersLoginPage, simulates user behavior entering email and password into the corresponding inputs via thedata-testidattribute, then clicks the submit button and finally checks if the router'spushfunction is called to redirect the user to the/homepage.
After that, you start writing code for the file app/login/page.tsx to pass the test above.
'use client'
import {LockOutlined, UserOutlined} from '@ant-design/icons'
import {Button, Card, Form, Input, message} from 'antd'
import {useRouter} from 'next/navigation'
import {useState} from 'react'
export default function LoginPage() {
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(values: any) {
setLoading(true)
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(values),
})
if (res.ok) {
message.success('Login successful!')
router.push('/home')
}
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-4">
<Card className="w-full max-w-md shadow-md rounded-lg">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-slate-800">Form Login</h1>
<p className="text-slate-500 text-sm mt-1">
Please sign in to your account
</p>
</div>
<Form
name="login_form"
layout="vertical"
onFinish={handleSubmit}
autoComplete="off"
requiredMark={false}
>
<Form.Item
name="email"
label="Email"
rules={[
{required: true, message: 'Please input your email!'},
{type: 'email', message: 'Please enter a valid email!'},
]}
>
<Input
prefix={<UserOutlined className="text-slate-400" />}
placeholder="example@domain.com"
className="py-2"
data-testid="login-email-input"
/>
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={[{required: true, message: 'Please input your your password!'}]}
>
<Input.Password
prefix={<LockOutlined className="text-slate-400" />}
placeholder="••••••••"
className="py-2"
data-testid="login-password-input"
/>
</Form.Item>
<Form.Item className="mb-0 mt-6">
<Button
type="primary"
htmlType="submit"
className="w-full bg-blue-600 h-10 text-base font-medium"
loading={loading}
data-testid="login-submit-button"
>
Sign In
</Button>
</Form.Item>
</Form>
</Card>
</div>
)
}
This code defines a Client Component in Next.js which is the login page interface:
- The component renders a Form consisting of two main input fields: Email and Password, along with a "Sign In" button.
- The
InputandButtonelements are all attached with thedata-testidattribute (e.g.,login-email-input,login-password-input,login-submit-button) serving directly for identifying elements in testing files. - When the Form is filled validly and submitted, the
handleSubmitfunction will be triggered: performing a call to the API endpoint/api/loginusing thePOSTmethod sending along user data as JSON. If the response from the API is successful (res.ok), the system will navigate the user to the home page/homeusingrouter.push.
Next, let's write the test scenario for the case where the user fails to log in due to entering the wrong password.
it('should show error message and not redirect when password is wrong', async () => {
server.use(
http.post('/api/login', () => {
return HttpResponse.json(
{success: false, message: 'Invalid credentials'},
{status: 401}
)
})
)
const push = vi.fn()
vi.mocked(useRouter).mockReturnValue({push} as any)
render(<LoginPage />)
fireEvent.change(screen.getByTestId('login-email-input'), {
target: {value: 'test@example.com'},
})
fireEvent.change(screen.getByTestId('login-password-input'), {
target: {value: 'wrong_password'},
})
fireEvent.click(screen.getByTestId('login-submit-button'))
await waitFor(() => {
expect(message.error).toHaveBeenCalledWith('Invalid credentials')
expect(push).not.toHaveBeenCalled()
})
})
- Uses
server.useto reconfigure the response of the API/api/loginlocally for this test case alone, forcing it to return an HTTP 401 Unauthorized error code along with an error message. - When the user enters the wrong username and password
- Check if the application calls the function to display the error message
message.errorwith the exact content 'Invalid credentials'. - Check and ensure that the router's redirect method
pushis not called at all, meaning the user remains on the login page.
You just need to modify the handleSubmit function to handle the above test case, simply adding a case and catching errors for the failure scenario.
async function handleSubmit(values: any) {
setLoading(true)
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(values),
})
if (res.ok) {
message.success('Login successful!')
router.push('/home')
} else {
message.error('Invalid credentials')
}
} catch (error) {
message.error('Something went wrong')
} finally {
setLoading(false)
}
}
The test run results are as follows:
yarn test:vi
✓ test/Login/integration.test.tsx (2 tests) 177ms
✓ Login Functionality (2)
✓ should redirect to home on successful login 145ms
✓ should show error message and not redirect when password is wrong 30ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 19:40:56
Duration 2.75s
PASS Waiting for file changes...
press h to show help, press q to quit
I only make a sample of 2 cases like that, you can implement the remaining test cases yourselves as follows:
- Leaving mandatory fields blank (Required Fields)
- Incorrect email format (Format Validation)
- System encounters an issue (500 Internal Server Error)
- Processing status (Loading State)
Happy coding!
Comments
Post a Comment