Hey teman-teman developer! Kalo kamu udah lumayan lama ngulik React JS, pasti tau dong bahwa nulis kode aja gak cukup. Kita butuh cara buat mastiin kode yang udah kita tulis bener-bener berfungsi dengan baik dan gak bikin masalah di kemudian hari. Nah, di sinilah unit testing masuk sebagai penyelamat.
Unit testing itu ibarat quality control di pabrik. Bayangin aja kalo kamu produksi mobil tapi gak pernah ngetes rem-nya, bahaya kan? Sama halnya dengan aplikasi React yang kita bikin. Tanpa testing, kita gak akan tau apakah komponen yang kita buat berfungsi sesuai ekspektasi atau malah rusak waktu ada perubahan kode.
Yang lebih parah lagi, bug baru bisa ketahuan sama user, dan itu definitely bukan pengalaman yang menyenangkan buat mereka maupun buat kita sebagai developer.
Kenapa Vitest dan Testing Library?
Di ekosistem React modern, kombinasi Vitest dan Testing Library udah jadi standar emas buat unit testing. Kenapa?
Pertama, Vitest itu super cepat karena dibuat khusus buat Vite ecosystem. Kalo kamu pernah pake Jest sebelumnya, transisinya bakal smooth banget karena API-nya hampir identik.
Kedua, React Testing Library punya filosofi yang unik yaitu "test your software the way your users use it". Jadi instead of nguji internal state atau method yang user gak liat, kita nguji apakah komponen behave sesuai ekspektasi dari perspektif user. Ini bikin test kita lebih robust dan less fragile waktu ada refactoring.
Apa yang bakal kamu pelajari?
Dalam artikel ini, gue bakal ngebawa kamu step by step buat nguasain 10 jenis unit testing yang paling sering dipake di production:
- Testing component rendering
- Testing props validation
- Testing conditional rendering
- Testing button click events
- Testing state changes
- Testing form input
- Testing form validation
- Testing form submission
- Testing keyboard interactions
- Testing loading states
Setiap section bakal dilengkapi dengan code example yang real dan relevan dengan project BuildWithAngga, jadi kamu bisa langsung praktek dan paham konteksnya.
Siapa yang cocok baca artikel ini?
Target artikel ini adalah teman-teman developer yang udah familiar dengan React JS tapi belum pernah atau baru mulai belajar testing. Kalo kamu udah bisa bikin component, handle state, dan manage props, maka kamu udah siap buat belajar testing.
Gak perlu khawatir kalo belum pernah sentuh testing sama sekali, karena gue bakal jelasin semuanya dari nol dengan bahasa yang santai dan mudah dipahami. Yang penting kamu punya mindset buat belajar dan mau praktek langsung, karena testing itu skill yang harus dilatih, gak bisa cuma baca teori doang.
Setelah selesai baca artikel ini, kamu bakal bisa nulis test buat hampir semua scenario yang ada di aplikasi React. Skill ini bukan cuma bikin kamu lebih percaya diri waktu push code, tapi juga naikin market value kamu sebagai developer.
Di job market sekarang, developer yang bisa nulis test dengan baik masih jarang dan highly demanded. So, let's get started dan upgrade skill kamu ke level berikutnya!
Persiapan Environment
Sebelum kita mulai nulis test, kita harus setup environment dulu. Ini kayak nyiapin dapur sebelum masak - semua alat dan bahan harus ready biar proses masak lancar. Di section ini, kita bakal setup project React dari nol sampe siap dipake buat testing.
Membuat Project React dengan Vite
Langkah pertama adalah bikin project React pake Vite. Kenapa Vite? Karena dia jauh lebih cepat dibanding Create React App dan udah jadi standar baru buat React development. Plus, konfigurasinya simpel dan straightforward.
Buka terminal kamu dan jalankan command ini:
npm create vite@latest
Nanti akan muncul prompt interactive. Isi seperti ini:
- Project name: ketik
bwa-testing-tutorial - Select a framework: pilih
React - Select a variant: pilih
TypeScript
Kenapa pake TypeScript? Karena TypeScript ngasih type safety yang bikin bug lebih gampang ketahuan, plus autocomplete di IDE jadi lebih mantap. Ini investasi yang worth it buat jangka panjang.
Setelah project kebuat, masuk ke folder project dan install dependencies:
cd bwa-testing-tutorial
npm install
Test dulu apakah project udah jalan dengan baik:
npm run dev

Buka browser ke http://localhost:5173 dan pastiin halaman React muncul dengan baik. Kalo udah muncul, berarti setup awal berhasil.
Install Dependencies Testing
Sekarang waktunya install semua library yang dibutuhin buat testing. Ini adalah paket-paket penting yang bakal kita pake:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @types/jest @testing-library/dom
Mari kita breakdown apa fungsi masing-masing package:
vitest - Ini adalah test runner yang dibuat khusus buat Vite. Dia compatible dengan Jest API tapi jauh lebih cepet karena leverage Vite's transformation pipeline.
@testing-library/react - Library utama buat render dan test React components. Filosofinya fokus ke user behavior, bukan implementation details.
@testing-library/jest-dom - Ngasih custom matchers yang bikin assertions lebih readable, kayak toBeVisible(), toHaveClass(), dan lain-lain.
@testing-library/user-event - Buat simulate user interactions dengan cara yang lebih realistic dibanding fireEvent biasa.
jsdom - DOM implementation buat Node.js environment, karena test jalan di Node bukan di browser.
@types/jest - Type definitions buat Jest/Vitest functions. Penting buat TypeScript supaya recognize fungsi-fungsi kayak describe, it, dan expect.
Konfigurasi Vite untuk Testing
Setelah install dependencies, kita perlu konfigurasi Vite biar tau gimana cara handle test files. Buka file vite.config.ts dan ubah jadi kayak gini:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
},
})
Penjelasan konfigurasi di atas:
globals: true - Ini bikin kita bisa pake describe, it, expect tanpa perlu import explicit di setiap test file. Jadi lebih praktis.
environment: 'jsdom' - Set jsdom sebagai browser environment simulator biar DOM API bisa dipake di test.
setupFiles - File yang dijalanin sebelum semua test. Biasanya buat global config atau import yang diperlukan semua test.
css: true - Enable CSS processing di test environment. Penting kalo komponen kita pake CSS modules atau styled components.
Konfigurasi TypeScript
Buka file tsconfig.app.json dan tambahin konfigurasi buat support Vitest globals:
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Custom */
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
Yang penting di sini adalah bagian types yang include vitest/globals dan @testing-library/jest-dom. Ini bikin TypeScript recognize semua function testing tanpa error.
Setup File untuk Testing
Sekarang kita bikin setup file yang tadi udah kita define di konfigurasi. Buat folder test di dalam src:
mkdir src/test
Lalu buat file setup.ts di dalemnya:
import '@testing-library/jest-dom'
File ini simpel tapi crucial. Dengan import jest-dom di sini, semua custom matchers jadi available di semua test files kita tanpa perlu import berulang-ulang.
Tambah Script Testing
Buka package.json dan tambahin script-script ini buat jalanin test:
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
Penjelasan masing-masing script:
test- Run tests dalam watch mode, otomatis re-run kalo ada perubahantest:ui- Buka Vitest UI interface di browser, berguna buat debugging visualtest:run- Run tests sekali aja tanpa watch, biasanya dipake di CI/CDtest:coverage- Run tests dengan coverage report buat liat seberapa banyak kode yang ke-test
Struktur Folder yang Recommended
Buat organization yang baik, kita susun folder testing kayak gini:
src/
├── components/
│ ├── CourseCard/
│ │ ├── CourseCard.tsx
│ │ ├── CourseCard.test.tsx
│ │ ├── CourseCard.types.ts
│ │ └── CourseCard.css
│ └── CourseSearchForm/
│ ├── CourseSearchForm.tsx
│ ├── CourseSearchForm.test.tsx
│ ├── CourseSearchForm.types.ts
│ └── CourseSearchForm.css
├── test/
│ └── setup.ts
└── App.tsx
Prinsip pentingnya adalah letakkan test files sejajar dengan component yang dites. Ini bikin easier buat maintain dan mencari files. Kalo komponen pindah, test-nya juga ikut pindah.
Naming Conventions
Buat consistency, pake naming conventions ini:
- React components:
.tsxdan.test.tsx - Utility functions:
.tsdan.test.ts - Type definitions:
.types.ts - Global types: di folder
types/
Verifikasi Setup Berhasil
Sekarang coba jalanin command test buat mastiin semuanya udah ter-setup dengan bener:
npm run test

Kalo muncul message "No test files found", itu bagus! Artinya Vitest udah jalan dengan baik, cuma memang belum ada test file yang dibuat. Kita bakal bikin test files di section-section berikutnya.
Environment testing kita sekarang udah siap 100%. Semua dependencies udah terinstall, konfigurasi udah bener, dan struktur folder udah tertata rapi. Sekarang kita bisa fokus ke hal yang lebih seru yaitu nulis test pertama kita!
Unit Test #1: Testing Component Rendering
Oke, sekarang kita masuk ke test pertama yang paling fundamental: testing component rendering. Ini adalah dasar dari semua jenis testing yang bakal kita pelajarin. Kalo kamu bisa nguasain konsep ini, test-test selanjutnya bakal jauh lebih gampang dipahami.
Konsep Dasar Render Component
Testing component rendering itu intinya adalah mastiin bahwa komponen React kita bisa muncul di layar dengan benar. Bayangin aja kayak kita ngecek apakah lampu nyala apa nggak - ini adalah hal paling basic tapi super penting. Tanpa test rendering yang solid, kita gak bisa lanjut ke test yang lebih kompleks.
Dalam konteks testing, "render" artinya kita bikin komponen hidup di virtual DOM yang bisa kita akses dan manipulasi. React Testing Library punya function render() yang tugasnya exactly buat ini. Function ini bakal return object berisi berbagai method buat berinteraksi dengan komponen yang udah di-render.
Yang perlu kamu pahami adalah test environment kita itu jalan di Node.js, bukan di browser beneran. Makanya kita butuh jsdom buat simulasi browser environment. Tapi tenang aja, semua udah kita setup di bagian sebelumnya, jadi sekarang tinggal pake aja.
Membuat Component CourseCard
Sebelum nulis test, kita bikin dulu component yang mau dites. Ini adalah component CourseCard yang biasa dipake di platform BuildWithAngga buat nampilin informasi course. Buat folder baru di src/components/CourseCard/ dan bikin file-file berikut.
Pertama, buat file CourseCard.types.ts buat define types:
export interface CourseCardProps {
title: string
instructor: string
price: number
thumbnail: string
category: string
}
Kedua, buat file CourseCard.tsx buat component-nya:
import { CourseCardProps } from './CourseCard.types'
import './CourseCard.css'
const CourseCard: React.FC<CourseCardProps> = ({
title,
instructor,
price,
thumbnail,
category
}) => {
return (
<div className="course-card" data-testid="course-card">
<div className="course-thumbnail">
<img src={thumbnail} alt={title} data-testid="course-thumbnail" />
</div>
<div className="course-content">
<div className="course-category" data-testid="course-category">
{category}
</div>
<h3 className="course-title" data-testid="course-title">
{title}
</h3>
<p className="course-instructor" data-testid="course-instructor">
Instruktur: {instructor}
</p>
<div className="course-price" data-testid="course-price">
{price === 0 ? 'Gratis' : `Rp ${price.toLocaleString('id-ID')}`}
</div>
</div>
</div>
)
}
export default CourseCard
Perhatiin bahwa setiap element penting kita kasih data-testid. Ini adalah attribute khusus yang bikin element gampang dicari waktu testing. Ini best practice yang highly recommended.
Ketiga, buat file CourseCard.css buat styling:
.course-card {
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
background: white;
transition: transform 0.2s;
}
.course-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.course-thumbnail {
width: 100%;
height: 200px;
overflow: hidden;
}
.course-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.course-content {
padding: 16px;
}
.course-category {
font-size: 12px;
color: #6b7280;
margin-bottom: 8px;
text-transform: uppercase;
}
.course-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 8px 0;
color: #111827;
}
.course-instructor {
font-size: 14px;
color: #6b7280;
margin: 0 0 12px 0;
}
.course-price {
font-size: 20px;
font-weight: 700;
color: #10b981;
}
Menulis Test Pertama
Sekarang kita bikin test file. Buat file CourseCard.test.tsx di folder yang sama:
import { render, screen } from '@testing-library/react'
import CourseCard from './CourseCard'
import { CourseCardProps } from './CourseCard.types'
describe('CourseCard Component Rendering', () => {
const mockCourseData: CourseCardProps = {
title: 'Mastering React Testing dengan Vitest',
instructor: 'Angga Risky',
price: 299000,
thumbnail: '<https://images.unsplash.com/photo-1633356122544-f134324a6cee>',
category: 'Frontend Development'
}
it('should render course card component', () => {
render(<CourseCard {...mockCourseData} />)
const courseCard = screen.getByTestId('course-card')
expect(courseCard).toBeInTheDocument()
})
})
Mari kita breakdown code di atas step by step:
describe() - Ini adalah function buat grouping test. Parameter pertama adalah nama group, parameter kedua adalah callback yang berisi test-test.
mockCourseData - Ini adalah dummy data yang kita pake buat testing. Selalu buat mock data di luar test function supaya bisa dipake ulang.
it() atau test() - Ini adalah function buat nulis single test case. Parameter pertama adalah deskripsi test, parameter kedua adalah test function.
render() - Function dari Testing Library yang render component ke virtual DOM.
screen.getByTestId() - Method buat cari element berdasarkan data-testid attribute.
expect().toBeInTheDocument() - Assertion yang ngecek apakah element ada di DOM.
Testing Berbagai Element
Sekarang kita tambahin test buat ngecek apakah semua element penting muncul dengan benar:
describe('CourseCard Component Rendering', () => {
const mockCourseData: CourseCardProps = {
title: 'Mastering React Testing dengan Vitest',
instructor: 'Angga Risky',
price: 299000,
thumbnail: '<https://images.unsplash.com/photo-1633356122544-f134324a6cee>',
category: 'Frontend Development'
}
it('should render course card component', () => {
render(<CourseCard {...mockCourseData} />)
const courseCard = screen.getByTestId('course-card')
expect(courseCard).toBeInTheDocument()
})
it('should render course title correctly', () => {
render(<CourseCard {...mockCourseData} />)
const title = screen.getByTestId('course-title')
expect(title).toBeInTheDocument()
expect(title).toHaveTextContent('Mastering React Testing dengan Vitest')
})
it('should render instructor name correctly', () => {
render(<CourseCard {...mockCourseData} />)
const instructor = screen.getByTestId('course-instructor')
expect(instructor).toBeInTheDocument()
expect(instructor).toHaveTextContent('Instruktur: Angga Risky')
})
it('should render category correctly', () => {
render(<CourseCard {...mockCourseData} />)
const category = screen.getByTestId('course-category')
expect(category).toBeInTheDocument()
expect(category).toHaveTextContent('Frontend Development')
})
it('should render price correctly', () => {
render(<CourseCard {...mockCourseData} />)
const price = screen.getByTestId('course-price')
expect(price).toBeInTheDocument()
expect(price).toHaveTextContent('Rp 299.000')
})
it('should render thumbnail image with correct attributes', () => {
render(<CourseCard {...mockCourseData} />)
const thumbnail = screen.getByTestId('course-thumbnail')
expect(thumbnail).toBeInTheDocument()
expect(thumbnail).toHaveAttribute('src', mockCourseData.thumbnail)
expect(thumbnail).toHaveAttribute('alt', mockCourseData.title)
})
})
Menjalankan Test
Sekarang waktunya jalanin test yang udah kita buat. Buka terminal dan jalanin:
npm run test
Vitest bakal jalan dalam watch mode dan otomatis detect semua file test. Kamu bakal liat output kayak gini di terminal:

Kalo semua test passed dengan tanda centang hijau, berarti component kita udah render dengan benar! Ini adalah milestone pertama yang important banget.
Kenapa getByTestId?
Mungkin kamu bertanya-tanya kenapa kita pake getByTestId instead of cara lain? Testing Library sebenarnya punya hierarchy of queries yang direkomendasiin:
- getByRole - Paling direkomendasiin karena accessible
- getByLabelText - Bagus buat form elements
- getByPlaceholderText - Alternatif buat inputs
- getByText - Bagus buat content
- getByTestId - Last resort tapi praktis
Dalam praktek real-world, getByTestId sering dipake karena lebih stable dan gak depend sama content yang bisa berubah. Tapi idealnya combine berbagai query method sesuai context.
Tips Penting
Beberapa hal yang perlu kamu perhatiin waktu nulis test rendering:
- Selalu buat mock data yang realistic dan represent real-world scenario
- Test satu concern per test case, jangan campur-campur
- Kasih deskripsi test yang jelas dan descriptive
- Pake data-testid dengan naming yang consistent
- Re-render component di setiap test buat isolation yang baik
Dengan nguasain test rendering ini, kamu udah punya fondasi yang kuat buat lanjut ke test-test yang lebih advanced. Test rendering adalah building block dari semua jenis testing React component!
Unit Test #2: Testing Props Validation
Setelah kita bisa test rendering component, sekarang kita naik level ke testing props. Props adalah cara utama buat passing data ke component React, jadi testing props validation itu super critical. Kalo props handling-nya salah, component bisa render data yang gak sesuai atau bahkan error.
Kenapa Testing Props Itu Penting
Props adalah interface antara component kita dengan dunia luar. Bayangin props kayak kontrak - component kita promise bakal handle data dengan cara tertentu, dan kita harus mastiin promise itu ditepatin. Testing props validation memastiin component kita behave correctly dengan berbagai kombinasi data yang mungkin diterima.
Di BuildWithAngga, misalnya component CourseCard bisa dipake di berbagai tempat dengan data yang beda-beda. Ada course gratis, ada yang berbayar, ada yang punya rating, ada yang belum. Kita harus mastiin component handle semua scenario ini dengan baik.
Testing dengan Berbagai Props Value
Mari kita extend test CourseCard buat ngecek berbagai kombinasi props. Buat file test baru atau tambahin di CourseCard.test.tsx yang udah ada:
describe('CourseCard Props Validation', () => {
it('should render with all required props', () => {
const courseData: CourseCardProps = {
title: 'Belajar React dari Nol',
instructor: 'Angga Risky',
price: 250000,
thumbnail: '<https://images.unsplash.com/photo-1633356122544-f134324a6cee>',
category: 'Web Development'
}
render(<CourseCard {...courseData} />)
expect(screen.getByTestId('course-title')).toHaveTextContent('Belajar React dari Nol')
expect(screen.getByTestId('course-instructor')).toHaveTextContent('Instruktur: Angga Risky')
expect(screen.getByTestId('course-price')).toHaveTextContent('Rp 250.000')
expect(screen.getByTestId('course-category')).toHaveTextContent('Web Development')
})
it('should handle free course with zero price', () => {
const freeCourse: CourseCardProps = {
title: 'Pengenalan HTML & CSS',
instructor: 'John Doe',
price: 0,
thumbnail: '<https://images.unsplash.com/photo-1498050108023-c5249f4df085>',
category: 'Frontend Basics'
}
render(<CourseCard {...freeCourse} />)
const priceElement = screen.getByTestId('course-price')
expect(priceElement).toHaveTextContent('Gratis')
})
it('should handle paid course with correct price format', () => {
const paidCourse: CourseCardProps = {
title: 'Advanced React Patterns',
instructor: 'Jane Smith',
price: 1500000,
thumbnail: '<https://images.unsplash.com/photo-1461749280684-dccba630e2f6>',
category: 'Advanced Frontend'
}
render(<CourseCard {...paidCourse} />)
const priceElement = screen.getByTestId('course-price')
expect(priceElement).toHaveTextContent('Rp 1.500.000')
})
})

Perhatiin gimana kita test berbagai value buat props price. Ini penting banget karena logic display price bisa berbeda tergantung valuenya - gratis atau berbayar.
Testing Props dengan TypeScript
Salah satu keuntungan pake TypeScript adalah kita dapet type checking otomatis. Tapi kita tetep perlu test runtime behavior. Mari kita update types buat include optional props dan test behavior-nya:
export interface CourseCardProps {
title: string
instructor: string
price: number
thumbnail: string
category: string
rating?: number
studentCount?: number
duration?: string
}
Update component CourseCard.tsx buat handle optional props:
const CourseCard: React.FC<CourseCardProps> = ({
title,
instructor,
price,
thumbnail,
category,
rating,
studentCount,
duration
}) => {
return (
<div className="course-card" data-testid="course-card">
<div className="course-thumbnail">
<img src={thumbnail} alt={title} data-testid="course-thumbnail" />
</div>
<div className="course-content">
<div className="course-category" data-testid="course-category">
{category}
</div>
<h3 className="course-title" data-testid="course-title">
{title}
</h3>
<p className="course-instructor" data-testid="course-instructor">
Instruktur: {instructor}
</p>
{rating && (
<div className="course-rating" data-testid="course-rating">
Rating: {rating.toFixed(1)} / 5.0
</div>
)}
{studentCount && (
<div className="course-students" data-testid="course-students">
{studentCount.toLocaleString('id-ID')} siswa
</div>
)}
{duration && (
<div className="course-duration" data-testid="course-duration">
Durasi: {duration}
</div>
)}
<div className="course-price" data-testid="course-price">
{price === 0 ? 'Gratis' : `Rp ${price.toLocaleString('id-ID')}`}
</div>
</div>
</div>
)
}
Testing Required vs Optional Props
Sekarang kita test behavior component dengan dan tanpa optional props:
describe('CourseCard Optional Props', () => {
const baseCourseData: CourseCardProps = {
title: 'Mastering TypeScript',
instructor: 'Angga Risky',
price: 350000,
thumbnail: '<https://images.unsplash.com/photo-1516116216624-53e697fedbea>',
category: 'Programming'
}
it('should render without optional props', () => {
render(<CourseCard {...baseCourseData} />)
expect(screen.getByTestId('course-title')).toBeInTheDocument()
expect(screen.queryByTestId('course-rating')).not.toBeInTheDocument()
expect(screen.queryByTestId('course-students')).not.toBeInTheDocument()
expect(screen.queryByTestId('course-duration')).not.toBeInTheDocument()
})
it('should render rating when provided', () => {
const courseWithRating = {
...baseCourseData,
rating: 4.8
}
render(<CourseCard {...courseWithRating} />)
const rating = screen.getByTestId('course-rating')
expect(rating).toBeInTheDocument()
expect(rating).toHaveTextContent('Rating: 4.8 / 5.0')
})
it('should render student count when provided', () => {
const courseWithStudents = {
...baseCourseData,
studentCount: 1250
}
render(<CourseCard {...courseWithStudents} />)
const students = screen.getByTestId('course-students')
expect(students).toBeInTheDocument()
expect(students).toHaveTextContent('1.250 siswa')
})
it('should render duration when provided', () => {
const courseWithDuration = {
...baseCourseData,
duration: '8 jam 30 menit'
}
render(<CourseCard {...courseWithDuration} />)
const duration = screen.getByTestId('course-duration')
expect(duration).toBeInTheDocument()
expect(duration).toHaveTextContent('Durasi: 8 jam 30 menit')
})
it('should render all optional props together', () => {
const fullCourseData = {
...baseCourseData,
rating: 4.9,
studentCount: 3420,
duration: '12 jam 15 menit'
}
render(<CourseCard {...fullCourseData} />)
expect(screen.getByTestId('course-rating')).toBeInTheDocument()
expect(screen.getByTestId('course-students')).toBeInTheDocument()
expect(screen.getByTestId('course-duration')).toBeInTheDocument()
})
})
Perhatiin perbedaan antara getByTestId dan queryByTestId. Kita pake queryByTestId buat element yang mungkin gak ada, karena dia return null instead of throw error.

Testing Edge Cases Props
Selain test happy path, kita juga harus test edge cases - scenario ekstrim yang mungkin terjadi:
describe('CourseCard Props Edge Cases', () => {
it('should handle very long title', () => {
const longTitleCourse: CourseCardProps = {
title: 'Belajar Fullstack Web Development dari Dasar hingga Mahir dengan Node.js, React, dan MongoDB untuk Pemula',
instructor: 'Angga Risky',
price: 450000,
thumbnail: '<https://images.unsplash.com/photo-1498050108023-c5249f4df085>',
category: 'Fullstack'
}
render(<CourseCard {...longTitleCourse} />)
const title = screen.getByTestId('course-title')
expect(title).toHaveTextContent(longTitleCourse.title)
})
it('should handle instructor name with special characters', () => {
const specialCharCourse: CourseCardProps = {
title: 'React Native Fundamentals',
instructor: "O'Connor-Smith Jr.",
price: 299000,
thumbnail: '<https://images.unsplash.com/photo-1516116216624-53e697fedbea>',
category: 'Mobile Development'
}
render(<CourseCard {...specialCharCourse} />)
expect(screen.getByTestId('course-instructor')).toHaveTextContent("Instruktur: O'Connor-Smith Jr.")
})
it('should handle very high price', () => {
const expensiveCourse: CourseCardProps = {
title: 'Enterprise Architecture Bootcamp',
instructor: 'Senior Architect',
price: 25000000,
thumbnail: '<https://images.unsplash.com/photo-1461749280684-dccba630e2f6>',
category: 'Architecture'
}
render(<CourseCard {...expensiveCourse} />)
expect(screen.getByTestId('course-price')).toHaveTextContent('Rp 25.000.000')
})
it('should handle rating with decimal precision', () => {
const preciseRatingCourse: CourseCardProps = {
title: 'Advanced JavaScript',
instructor: 'John Doe',
price: 199000,
thumbnail: '<https://images.unsplash.com/photo-1633356122544-f134324a6cee>',
category: 'JavaScript',
rating: 4.87654
}
render(<CourseCard {...preciseRatingCourse} />)
const rating = screen.getByTestId('course-rating')
expect(rating).toHaveTextContent('Rating: 4.9 / 5.0')
})
it('should handle large student count', () => {
const popularCourse: CourseCardProps = {
title: 'Python for Data Science',
instructor: 'Data Expert',
price: 0,
thumbnail: '<https://images.unsplash.com/photo-1498050108023-c5249f4df085>',
category: 'Data Science',
studentCount: 125430
}
render(<CourseCard {...popularCourse} />)
expect(screen.getByTestId('course-students')).toHaveTextContent('125.430 siswa')
})
})

Verifikasi Data Display
Yang gak kalah penting adalah mastiin data yang ditampilkan sesuai dengan props yang dikirim. Ini termasuk format number, handling null/undefined, dan transformasi data:
describe('CourseCard Data Display Verification', () => {
it('should format price correctly with Indonesian locale', () => {
const courses = [
{ price: 150000, expected: 'Rp 150.000' },
{ price: 1250000, expected: 'Rp 1.250.000' },
{ price: 99000, expected: 'Rp 99.000' }
]
courses.forEach(({ price, expected }) => {
const courseData: CourseCardProps = {
title: 'Test Course',
instructor: 'Test Instructor',
price,
thumbnail: 'test.jpg',
category: 'Test'
}
const { unmount } = render(<CourseCard {...courseData} />)
expect(screen.getByTestId('course-price')).toHaveTextContent(expected)
unmount()
})
})
it('should display thumbnail with correct src and alt', () => {
const courseData: CourseCardProps = {
title: 'React Hooks Deep Dive',
instructor: 'Hook Master',
price: 275000,
thumbnail: '<https://example.com/react-hooks.jpg>',
category: 'React'
}
render(<CourseCard {...courseData} />)
const thumbnail = screen.getByTestId('course-thumbnail')
expect(thumbnail).toHaveAttribute('src', courseData.thumbnail)
expect(thumbnail).toHaveAttribute('alt', courseData.title)
})
it('should prepend "Instruktur:" to instructor name', () => {
const courseData: CourseCardProps = {
title: 'Vue.js Essentials',
instructor: 'Vue Expert',
price: 225000,
thumbnail: 'vue.jpg',
category: 'Vue'
}
render(<CourseCard {...courseData} />)
const instructor = screen.getByTestId('course-instructor')
expect(instructor.textContent).toContain('Instruktur:')
expect(instructor.textContent).toContain('Vue Expert')
})
})

Running Tests
Jalankan test buat mastiin semua props handling berjalan dengan baik:
npm run test

Kamu harusnya liat output dengan banyak test yang passed. Kalo ada yang failed, baca error messagenya dengan teliti - biasanya Vitest ngasih info yang jelas tentang apa yang salah.
Best Practices Testing Props
Beberapa tips penting waktu testing props:
- Test dengan data yang realistic dan represent real-world usage
- Jangan lupa test edge cases kayak empty strings, very large numbers, atau special characters
- Pake type system TypeScript buat catch error di compile time
- Test both presence dan absence dari optional props
- Verify data transformation dan formatting
- Gunakan
queryBybuat element yang conditional
Dengan nguasain props testing, kamu udah bisa ensure bahwa component handle data dengan benar dalam berbagai scenario. Ini adalah skill fundamental yang bakal kepake terus dalam career development kamu!
Unit Test #3: Testing Conditional Rendering
Conditional rendering adalah salah satu pattern paling umum di React. Component kita sering nampilin atau nyembunyiin element berdasarkan kondisi tertentu - misalnya badge "New" cuma muncul buat course baru, atau rating cuma ditampilin kalo udah ada yang ngasih review. Testing conditional rendering memastiin logic ini berfungsi dengan benar.
Konsep Conditional Rendering dalam Testing
Waktu kita test conditional rendering, yang kita cek adalah apakah element muncul atau tidak berdasarkan props atau state tertentu. Ini beda sama test rendering biasa yang assume element pasti ada. Di sini kita harus bisa handle scenario dimana element mungkin ada atau gak ada.
Testing Library punya dua jenis query buat handle ini: getBy yang throw error kalo element gak ketemu, dan queryBy yang return null. Buat conditional rendering, kita mostly pake queryBy karena kita expect element bisa ada atau tidak.
Update Component dengan Conditional Elements
Mari kita update CourseCard buat include beberapa conditional rendering. Pertama update types di CourseCard.types.ts:
export interface CourseCardProps {
title: string
instructor: string
price: number
thumbnail: string
category: string
rating?: number
studentCount?: number
duration?: string
isNew?: boolean
discount?: number
isBestseller?: boolean
}
Sekarang update component CourseCard.tsx dengan conditional rendering:
const CourseCard: React.FC<CourseCardProps> = ({
title,
instructor,
price,
thumbnail,
category,
rating,
studentCount,
duration,
isNew,
discount,
isBestseller
}) => {
const calculateDiscountedPrice = () => {
if (discount && discount > 0) {
return price - (price * discount / 100)
}
return price
}
const finalPrice = calculateDiscountedPrice()
return (
<div className="course-card" data-testid="course-card">
<div className="course-thumbnail">
<img src={thumbnail} alt={title} data-testid="course-thumbnail" />
{isNew && (
<span className="badge-new" data-testid="badge-new">
Baru
</span>
)}
{isBestseller && (
<span className="badge-bestseller" data-testid="badge-bestseller">
Terlaris
</span>
)}
</div>
<div className="course-content">
<div className="course-category" data-testid="course-category">
{category}
</div>
<h3 className="course-title" data-testid="course-title">
{title}
</h3>
<p className="course-instructor" data-testid="course-instructor">
Instruktur: {instructor}
</p>
{rating && rating > 0 && (
<div className="course-rating" data-testid="course-rating">
Rating: {rating.toFixed(1)} / 5.0
</div>
)}
{studentCount && studentCount > 0 && (
<div className="course-students" data-testid="course-students">
{studentCount.toLocaleString('id-ID')} siswa terdaftar
</div>
)}
{duration && (
<div className="course-duration" data-testid="course-duration">
Durasi: {duration}
</div>
)}
<div className="course-price-section">
{discount && discount > 0 && (
<div className="price-discount" data-testid="price-discount">
<span className="original-price" data-testid="original-price">
Rp {price.toLocaleString('id-ID')}
</span>
<span className="discount-badge" data-testid="discount-badge">
{discount}% OFF
</span>
</div>
)}
<div className="course-price" data-testid="course-price">
{finalPrice === 0 ? 'Gratis' : `Rp ${finalPrice.toLocaleString('id-ID')}`}
</div>
</div>
</div>
</div>
)
}
Testing Element yang Tidak Muncul
Ini adalah test dasar buat mastiin element conditional gak muncul kalo kondisinya gak terpenuhi:
describe('CourseCard Conditional Rendering', () => {
const baseCourseData: CourseCardProps = {
title: 'React Performance Optimization',
instructor: 'Angga Risky',
price: 399000,
thumbnail: '<https://images.unsplash.com/photo-1633356122544-f134324a6cee>',
category: 'React Advanced'
}
it('should not render optional badges when not provided', () => {
render(<CourseCard {...baseCourseData} />)
expect(screen.queryByTestId('badge-new')).not.toBeInTheDocument()
expect(screen.queryByTestId('badge-bestseller')).not.toBeInTheDocument()
})
it('should not render rating when not provided', () => {
render(<CourseCard {...baseCourseData} />)
expect(screen.queryByTestId('course-rating')).not.toBeInTheDocument()
})
it('should not render student count when not provided', () => {
render(<CourseCard {...baseCourseData} />)
expect(screen.queryByTestId('course-students')).not.toBeInTheDocument()
})
it('should not render duration when not provided', () => {
render(<CourseCard {...baseCourseData} />)
expect(screen.queryByTestId('course-duration')).not.toBeInTheDocument()
})
it('should not render discount section when no discount', () => {
render(<CourseCard {...baseCourseData} />)
expect(screen.queryByTestId('price-discount')).not.toBeInTheDocument()
expect(screen.queryByTestId('original-price')).not.toBeInTheDocument()
expect(screen.queryByTestId('discount-badge')).not.toBeInTheDocument()
})
})
Perhatiin kita pake queryByTestId dan expect dengan not.toBeInTheDocument(). Ini adalah pattern standard buat test element yang seharusnya gak ada.

Testing Element yang Muncul Berdasarkan Kondisi
Sekarang kita test scenario dimana element muncul kalo kondisi terpenuhi:
describe('CourseCard Conditional Elements Appear', () => {
const baseCourseData: CourseCardProps = {
title: 'Next.js 14 Complete Guide',
instructor: 'Web Developer',
price: 450000,
thumbnail: '<https://images.unsplash.com/photo-1461749280684-dccba630e2f6>',
category: 'Next.js'
}
it('should render "Baru" badge when isNew is true', () => {
const newCourse = { ...baseCourseData, isNew: true }
render(<CourseCard {...newCourse} />)
const newBadge = screen.getByTestId('badge-new')
expect(newBadge).toBeInTheDocument()
expect(newBadge).toHaveTextContent('Baru')
})
it('should render "Terlaris" badge when isBestseller is true', () => {
const bestsellerCourse = { ...baseCourseData, isBestseller: true }
render(<CourseCard {...bestsellerCourse} />)
const bestsellerBadge = screen.getByTestId('badge-bestseller')
expect(bestsellerBadge).toBeInTheDocument()
expect(bestsellerBadge).toHaveTextContent('Terlaris')
})
it('should render both badges when both conditions are true', () => {
const specialCourse = {
...baseCourseData,
isNew: true,
isBestseller: true
}
render(<CourseCard {...specialCourse} />)
expect(screen.getByTestId('badge-new')).toBeInTheDocument()
expect(screen.getByTestId('badge-bestseller')).toBeInTheDocument()
})
it('should render rating when provided and greater than zero', () => {
const ratedCourse = { ...baseCourseData, rating: 4.7 }
render(<CourseCard {...ratedCourse} />)
const rating = screen.getByTestId('course-rating')
expect(rating).toBeInTheDocument()
expect(rating).toHaveTextContent('Rating: 4.7 / 5.0')
})
it('should render student count when provided and greater than zero', () => {
const popularCourse = { ...baseCourseData, studentCount: 2500 }
render(<CourseCard {...popularCourse} />)
const students = screen.getByTestId('course-students')
expect(students).toBeInTheDocument()
expect(students).toHaveTextContent('2.500 siswa terdaftar')
})
it('should render duration when provided', () => {
const courseWithDuration = { ...baseCourseData, duration: '10 jam' }
render(<CourseCard {...courseWithDuration} />)
const duration = screen.getByTestId('course-duration')
expect(duration).toBeInTheDocument()
expect(duration).toHaveTextContent('Durasi: 10 jam')
})
})

Testing Discount Logic
Discount adalah contoh bagus dari conditional rendering yang juga involve calculation. Kita harus test apakah discount section muncul dan apakah perhitungannya benar:
describe('CourseCard Discount Conditional Rendering', () => {
const baseCourseData: CourseCardProps = {
title: 'Vue.js Mastery',
instructor: 'Frontend Expert',
price: 500000,
thumbnail: '<https://images.unsplash.com/photo-1498050108023-c5249f4df085>',
category: 'Vue.js'
}
it('should render discount section when discount is provided', () => {
const discountedCourse = { ...baseCourseData, discount: 30 }
render(<CourseCard {...discountedCourse} />)
expect(screen.getByTestId('price-discount')).toBeInTheDocument()
expect(screen.getByTestId('original-price')).toBeInTheDocument()
expect(screen.getByTestId('discount-badge')).toBeInTheDocument()
})
it('should display original price correctly in discount section', () => {
const discountedCourse = { ...baseCourseData, discount: 25 }
render(<CourseCard {...discountedCourse} />)
const originalPrice = screen.getByTestId('original-price')
expect(originalPrice).toHaveTextContent('Rp 500.000')
})
it('should display discount percentage correctly', () => {
const discountedCourse = { ...baseCourseData, discount: 40 }
render(<CourseCard {...discountedCourse} />)
const discountBadge = screen.getByTestId('discount-badge')
expect(discountBadge).toHaveTextContent('40% OFF')
})
it('should calculate and display discounted price correctly', () => {
const discountedCourse = { ...baseCourseData, discount: 20 }
render(<CourseCard {...discountedCourse} />)
// Original: 500.000, Discount 20% = 400.000
const finalPrice = screen.getByTestId('course-price')
expect(finalPrice).toHaveTextContent('Rp 400.000')
})
it('should not render discount section when discount is zero', () => {
const noDiscountCourse = { ...baseCourseData, discount: 0 }
render(<CourseCard {...noDiscountCourse} />)
expect(screen.queryByTestId('price-discount')).not.toBeInTheDocument()
expect(screen.getByTestId('course-price')).toHaveTextContent('Rp 500.000')
})
})

Testing Edge Cases Conditional Rendering
Kita juga perlu test edge cases buat conditional rendering, misalnya nilai yang borderline atau kombinasi props yang unusual:
describe('CourseCard Conditional Rendering Edge Cases', () => {
const baseCourseData: CourseCardProps = {
title: 'Tailwind CSS Pro',
instructor: 'CSS Master',
price: 299000,
thumbnail: '<https://images.unsplash.com/photo-1516116216624-53e697fedbea>',
category: 'CSS'
}
it('should not render rating when rating is zero', () => {
const zeroRatingCourse = { ...baseCourseData, rating: 0 }
render(<CourseCard {...zeroRatingCourse} />)
expect(screen.queryByTestId('course-rating')).not.toBeInTheDocument()
})
it('should not render student count when count is zero', () => {
const noStudentsCourse = { ...baseCourseData, studentCount: 0 }
render(<CourseCard {...noStudentsCourse} />)
expect(screen.queryByTestId('course-students')).not.toBeInTheDocument()
})
it('should render rating with very low value', () => {
const lowRatingCourse = { ...baseCourseData, rating: 0.1 }
render(<CourseCard {...lowRatingCourse} />)
const rating = screen.getByTestId('course-rating')
expect(rating).toBeInTheDocument()
expect(rating).toHaveTextContent('Rating: 0.1 / 5.0')
})
it('should handle 100% discount correctly', () => {
const freeBecauseDiscountCourse = { ...baseCourseData, discount: 100 }
render(<CourseCard {...freeBecauseDiscountCourse} />)
expect(screen.getByTestId('discount-badge')).toHaveTextContent('100% OFF')
expect(screen.getByTestId('course-price')).toHaveTextContent('Gratis')
})
it('should render all conditional elements together', () => {
const fullFeaturedCourse = {
...baseCourseData,
isNew: true,
isBestseller: true,
rating: 4.9,
studentCount: 5000,
duration: '15 jam 30 menit',
discount: 35
}
render(<CourseCard {...fullFeaturedCourse} />)
expect(screen.getByTestId('badge-new')).toBeInTheDocument()
expect(screen.getByTestId('badge-bestseller')).toBeInTheDocument()
expect(screen.getByTestId('course-rating')).toBeInTheDocument()
expect(screen.getByTestId('course-students')).toBeInTheDocument()
expect(screen.getByTestId('course-duration')).toBeInTheDocument()
expect(screen.getByTestId('price-discount')).toBeInTheDocument()
})
})

Testing dengan Rerender
Kadang kita perlu test perubahan dari ada ke tidak ada atau sebaliknya. Testing Library punya method rerender buat ini:
describe('CourseCard Conditional Rendering Changes', () => {
it('should show badge when isNew changes from false to true', () => {
const courseData: CourseCardProps = {
title: 'Docker & Kubernetes',
instructor: 'DevOps Expert',
price: 550000,
thumbnail: '<https://images.unsplash.com/photo-1633356122544-f134324a6cee>',
category: 'DevOps',
isNew: false
}
const { rerender } = render(<CourseCard {...courseData} />)
expect(screen.queryByTestId('badge-new')).not.toBeInTheDocument()
// Update props
rerender(<CourseCard {...courseData} isNew={true} />)
expect(screen.getByTestId('badge-new')).toBeInTheDocument()
})
it('should hide discount section when discount is removed', () => {
const courseData: CourseCardProps = {
title: 'GraphQL Advanced',
instructor: 'API Specialist',
price: 400000,
thumbnail: '<https://images.unsplash.com/photo-1461749280684-dccba630e2f6>',
category: 'GraphQL',
discount: 25
}
const { rerender } = render(<CourseCard {...courseData} />)
expect(screen.getByTestId('price-discount')).toBeInTheDocument()
// Remove discount
rerender(<CourseCard {...courseData} discount={0} />)
expect(screen.queryByTestId('price-discount')).not.toBeInTheDocument()
})
})

Running Tests dan Debugging
Jalankan semua test conditional rendering:
npm run test CourseCard.test.tsx

Kalo ada test yang gagal, perhatiin error messagenya. Vitest biasanya ngasih info yang detail tentang expected vs actual value. Common issues waktu test conditional rendering:
- Lupa pake
queryByinstead ofgetBybuat element yang mungkin gak ada - Logic conditional di component yang salah atau typo
- Lupa handle edge case kayak nilai 0 atau empty string
- Data testid yang salah atau typo
Best Practices
Tips penting buat testing conditional rendering:
- Selalu test both presence dan absence dari conditional elements
- Pake
queryBybuat element yang conditional,getBybuat yang pasti ada - Test edge cases kayak nilai 0, negative, atau very large numbers
- Test kombinasi dari multiple conditional elements
- Verify content dari element yang muncul, jangan cuma check keberadaannya
- Gunakan rerender kalo perlu test perubahan state
Dengan nguasain conditional rendering testing, kamu bisa mastiin component handle berbagai scenario dengan benar. Skill ini crucial karena hampir semua component production punya conditional logic yang perlu di-test dengan baik!
Unit Test #4: Testing Button Click Events
Testing user interactions adalah salah satu aspek terpenting dalam unit testing. Button click adalah interaksi paling umum yang dilakuin user, jadi kita harus mastiin semua button berfungsi dengan benar. Di section ini, kita bakal belajar gimana cara test apakah callback function ke-trigger waktu button diklik.
Konsep Mock Function
Sebelum mulai test button click, kita perlu paham dulu apa itu mock function. Mock function adalah fake function yang kita bikin khusus buat testing. Fungsinya adalah buat track apakah function tersebut dipanggil, berapa kali dipanggil, dan dengan parameter apa.
Vitest punya function vi.fn() yang bikin mock function. Ini adalah spy function yang bisa record semua interaksi yang terjadi. Kita bisa cek apakah function ini dipanggil, berapa kali, dan bahkan apa aja argument yang dikirim ke function tersebut.
Update Component dengan Button Actions
Mari kita update CourseCard buat include button yang bisa diklik. Update CourseCard.types.ts:
export interface CourseCardProps {
title: string
instructor: string
price: number
thumbnail: string
category: string
rating?: number
studentCount?: number
duration?: string
isNew?: boolean
discount?: number
isBestseller?: boolean
onEnroll?: () => void
onAddToCart?: () => void
onWishlist?: () => void
}
Update component CourseCard.tsx dengan button interactions:
import { useState } from 'react'
import { CourseCardProps } from './CourseCard.types'
import './CourseCard.css'
const CourseCard: React.FC<CourseCardProps> = ({
title,
instructor,
price,
thumbnail,
category,
rating,
studentCount,
duration,
isNew,
discount,
isBestseller,
onEnroll,
onAddToCart,
onWishlist
}) => {
const [isWishlisted, setIsWishlisted] = useState(false)
const calculateDiscountedPrice = () => {
if (discount && discount > 0) {
return price - (price * discount / 100)
}
return price
}
const finalPrice = calculateDiscountedPrice()
const handleEnrollClick = () => {
if (onEnroll) {
onEnroll()
}
}
const handleAddToCartClick = () => {
if (onAddToCart) {
onAddToCart()
}
}
const handleWishlistClick = () => {
setIsWishlisted(!isWishlisted)
if (onWishlist) {
onWishlist()
}
}
return (
<div className="course-card" data-testid="course-card">
<div className="course-thumbnail">
<img src={thumbnail} alt={title} data-testid="course-thumbnail" />
{isNew && (
<span className="badge-new" data-testid="badge-new">
Baru
</span>
)}
{isBestseller && (
<span className="badge-bestseller" data-testid="badge-bestseller">
Terlaris
</span>
)}
<button
className={`wishlist-button ${isWishlisted ? 'active' : ''}`}
onClick={handleWishlistClick}
data-testid="wishlist-button"
aria-label={isWishlisted ? 'Hapus dari wishlist' : 'Tambah ke wishlist'}
>
{isWishlisted ? '♥' : '♡'}
</button>
</div>
<div className="course-content">
<div className="course-category" data-testid="course-category">
{category}
</div>
<h3 className="course-title" data-testid="course-title">
{title}
</h3>
<p className="course-instructor" data-testid="course-instructor">
Instruktur: {instructor}
</p>
{rating && rating > 0 && (
<div className="course-rating" data-testid="course-rating">
Rating: {rating.toFixed(1)} / 5.0
</div>
)}
{studentCount && studentCount > 0 && (
<div className="course-students" data-testid="course-students">
{studentCount.toLocaleString('id-ID')} siswa terdaftar
</div>
)}
{duration && (
<div className="course-duration" data-testid="course-duration">
Durasi: {duration}
</div>
)}
<div className="course-price-section">
{discount && discount > 0 && (
<div className="price-discount" data-testid="price-discount">
<span className="original-price" data-testid="original-price">
Rp {price.toLocaleString('id-ID')}
</span>
<span className="discount-badge" data-testid="discount-badge">
{discount}% OFF
</span>
</div>
)}
<div className="course-price" data-testid="course-price">
{finalPrice === 0 ? 'Gratis' : `Rp ${finalPrice.toLocaleString('id-ID')}`}
</div>
</div>
<div className="course-actions">
<button
className="btn-enroll"
onClick={handleEnrollClick}
data-testid="enroll-button"
>
{finalPrice === 0 ? 'Mulai Belajar' : 'Beli Sekarang'}
</button>
<button
className="btn-add-cart"
onClick={handleAddToCartClick}
data-testid="add-cart-button"
>
Tambah ke Keranjang
</button>
</div>
</div>
</div>
)
}
export default CourseCard
Testing Basic Button Click
Sekarang kita mulai test button click yang paling sederhana. Buat test baru di CourseCard.test.tsx:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import CourseCard from './CourseCard'
import { CourseCardProps } from './CourseCard.types'
describe('CourseCard Button Click Events', () => {
const baseCourseData: CourseCardProps = {
title: 'React Hooks Masterclass',
instructor: 'Angga Risky',
price: 350000,
thumbnail: '<https://images.unsplash.com/photo-1633356122544-f134324a6cee>',
category: 'React'
}
it('should call onEnroll when enroll button is clicked', async () => {
const mockOnEnroll = vi.fn()
const user = userEvent.setup()
render(<CourseCard {...baseCourseData} onEnroll={mockOnEnroll} />)
const enrollButton = screen.getByTestId('enroll-button')
await user.click(enrollButton)
expect(mockOnEnroll).toHaveBeenCalled()
expect(mockOnEnroll).toHaveBeenCalledTimes(1)
})
it('should call onAddToCart when add to cart button is clicked', async () => {
const mockOnAddToCart = vi.fn()
const user = userEvent.setup()
render(<CourseCard {...baseCourseData} onAddToCart={mockOnAddToCart} />)
const addCartButton = screen.getByTestId('add-cart-button')
await user.click(addCartButton)
expect(mockOnAddToCart).toHaveBeenCalled()
expect(mockOnAddToCart).toHaveBeenCalledTimes(1)
})
})

Mari kita breakdown code di atas:
vi.fn() - Bikin mock function yang bisa track semua calls
userEvent.setup() - Setup userEvent instance yang simulate user interactions dengan lebih realistic
await user.click() - Simulate click event. Harus pake await karena userEvent adalah async
toHaveBeenCalled() - Check apakah function dipanggil minimal sekali
toHaveBeenCalledTimes(1) - Check apakah function dipanggil exactly 1 kali
Testing Multiple Clicks
Kadang kita perlu test apakah button bisa diklik berulang kali:
describe('CourseCard Multiple Button Clicks', () => {
it('should call onEnroll multiple times when clicked repeatedly', async () => {
const mockOnEnroll = vi.fn()
const user = userEvent.setup()
render(
<CourseCard
{...baseCourseData}
onEnroll={mockOnEnroll}
/>
)
const enrollButton = screen.getByTestId('enroll-button')
await user.click(enrollButton)
await user.click(enrollButton)
await user.click(enrollButton)
expect(mockOnEnroll).toHaveBeenCalledTimes(3)
})
it('should handle rapid clicks correctly', async () => {
const mockOnAddToCart = vi.fn()
const user = userEvent.setup()
render(
<CourseCard
{...baseCourseData}
onAddToCart={mockOnAddToCart}
/>
)
const addCartButton = screen.getByTestId('add-cart-button')
// Simulate rapid clicks
await user.click(addCartButton)
await user.click(addCartButton)
expect(mockOnAddToCart).toHaveBeenCalledTimes(2)
})
})

Testing Button dengan State Changes
Wishlist button punya state internal yang berubah waktu diklik. Kita perlu test apakah state dan callback terpanggil dengan benar:
describe('CourseCard Wishlist Button with State', () => {
it('should call onWishlist when wishlist button is clicked', async () => {
const mockOnWishlist = vi.fn()
const user = userEvent.setup()
render(
<CourseCard
{...baseCourseData}
onWishlist={mockOnWishlist}
/>
)
const wishlistButton = screen.getByTestId('wishlist-button')
await user.click(wishlistButton)
expect(mockOnWishlist).toHaveBeenCalled()
expect(mockOnWishlist).toHaveBeenCalledTimes(1)
})
it('should toggle wishlist icon when clicked', async () => {
const user = userEvent.setup()
render(<CourseCard {...baseCourseData} />)
const wishlistButton = screen.getByTestId('wishlist-button')
// Initial state - not wishlisted
expect(wishlistButton).toHaveTextContent('♡')
expect(wishlistButton).toHaveAttribute('aria-label', 'Tambah ke wishlist')
// Click to add to wishlist
await user.click(wishlistButton)
expect(wishlistButton).toHaveTextContent('♥')
expect(wishlistButton).toHaveAttribute('aria-label', 'Hapus dari wishlist')
// Click again to remove
await user.click(wishlistButton)
expect(wishlistButton).toHaveTextContent('♡')
expect(wishlistButton).toHaveAttribute('aria-label', 'Tambah ke wishlist')
})
it('should call onWishlist each time when toggled multiple times', async () => {
const mockOnWishlist = vi.fn()
const user = userEvent.setup()
render(
<CourseCard
{...baseCourseData}
onWishlist={mockOnWishlist}
/>
)
const wishlistButton = screen.getByTestId('wishlist-button')
await user.click(wishlistButton) // Add
await user.click(wishlistButton) // Remove
await user.click(wishlistButton) // Add again
expect(mockOnWishlist).toHaveBeenCalledTimes(3)
})
})

Testing Button Text Conditional
Button enroll punya text yang berbeda tergantung apakah course gratis atau berbayar:
describe('CourseCard Button Text Conditional', () => {
it('should display "Beli Sekarang" for paid courses', () => {
const paidCourse = { ...baseCourseData, price: 299000 }
render(<CourseCard {...paidCourse} />)
const enrollButton = screen.getByTestId('enroll-button')
expect(enrollButton).toHaveTextContent('Beli Sekarang')
})
it('should display "Mulai Belajar" for free courses', () => {
const freeCourse = { ...baseCourseData, price: 0 }
render(<CourseCard {...freeCourse} />)
const enrollButton = screen.getByTestId('enroll-button')
expect(enrollButton).toHaveTextContent('Mulai Belajar')
})
it('should call onEnroll regardless of price', async () => {
const mockOnEnroll = vi.fn()
const user = userEvent.setup()
// Test with paid course
const { rerender } = render(
<CourseCard
{...baseCourseData}
price={299000}
onEnroll={mockOnEnroll}
/>
)
await user.click(screen.getByTestId('enroll-button'))
expect(mockOnEnroll).toHaveBeenCalledTimes(1)
// Test with free course
mockOnEnroll.mockClear()
rerender(
<CourseCard
{...baseCourseData}
price={0}
onEnroll={mockOnEnroll}
/>
)
await user.click(screen.getByTestId('enroll-button'))
expect(mockOnEnroll).toHaveBeenCalledTimes(1)
})
})

Testing Button Tanpa Callback
Penting juga test apakah component gak error waktu callback gak disediain:
describe('CourseCard Button without Callbacks', () => {
it('should not error when clicking enroll button without onEnroll prop', async () => {
const user = userEvent.setup()
render(<CourseCard {...baseCourseData} />)
const enrollButton = screen.getByTestId('enroll-button')
// Should not throw error
await expect(user.click(enrollButton)).resolves.not.toThrow()
})
it('should not error when clicking add cart button without onAddToCart prop', async () => {
const user = userEvent.setup()
render(<CourseCard {...baseCourseData} />)
const addCartButton = screen.getByTestId('add-cart-button')
await expect(user.click(addCartButton)).resolves.not.toThrow()
})
it('should not error when clicking wishlist button without onWishlist prop', async () => {
const user = userEvent.setup()
render(<CourseCard {...baseCourseData} />)
const wishlistButton = screen.getByTestId('wishlist-button')
await expect(user.click(wishlistButton)).resolves.not.toThrow()
})
})

Testing Multiple Buttons Simultaneously
Test scenario dimana user klik beberapa button dalam satu session:
describe('CourseCard Multiple Button Interactions', () => {
it('should handle clicks on different buttons independently', async () => {
const mockOnEnroll = vi.fn()
const mockOnAddToCart = vi.fn()
const mockOnWishlist = vi.fn()
const user = userEvent.setup()
render(
<CourseCard
{...baseCourseData}
onEnroll={mockOnEnroll}
onAddToCart={mockOnAddToCart}
onWishlist={mockOnWishlist}
/>
)
// Click different buttons
await user.click(screen.getByTestId('wishlist-button'))
await user.click(screen.getByTestId('add-cart-button'))
await user.click(screen.getByTestId('enroll-button'))
expect(mockOnWishlist).toHaveBeenCalledTimes(1)
expect(mockOnAddToCart).toHaveBeenCalledTimes(1)
expect(mockOnEnroll).toHaveBeenCalledTimes(1)
})
it('should maintain state correctly when clicking multiple buttons', async () => {
const user = userEvent.setup()
render(<CourseCard {...baseCourseData} />)
const wishlistButton = screen.getByTestId('wishlist-button')
// Click wishlist
await user.click(wishlistButton)
expect(wishlistButton).toHaveTextContent('♥')
// Click other buttons
await user.click(screen.getByTestId('enroll-button'))
await user.click(screen.getByTestId('add-cart-button'))
// Wishlist state should still be maintained
expect(wishlistButton).toHaveTextContent('♥')
})
})

Mock Function Best Practices
Beberapa tips penting waktu kerja dengan mock functions:
describe('CourseCard Mock Function Best Practices', () => {
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks()
})
it('should verify mock was called with correct context', async () => {
const mockOnEnroll = vi.fn()
const user = userEvent.setup()
render(
<CourseCard
{...baseCourseData}
onEnroll={mockOnEnroll}
/>
)
await user.click(screen.getByTestId('enroll-button'))
// Verify mock was called
expect(mockOnEnroll).toHaveBeenCalled()
// Can also check if it was the last call
expect(mockOnEnroll).toHaveBeenCalledTimes(1)
})
it('should reset mock between tests', async () => {
const mockOnEnroll = vi.fn()
const user = userEvent.setup()
// First render and click
const { unmount } = render(
<CourseCard
{...baseCourseData}
onEnroll={mockOnEnroll}
/>
)
await user.click(screen.getByTestId('enroll-button'))
expect(mockOnEnroll).toHaveBeenCalledTimes(1)
unmount()
mockOnEnroll.mockClear()
// Second render and click
render(
<CourseCard
{...baseCourseData}
onEnroll={mockOnEnroll}
/>
)
await user.click(screen.getByTestId('enroll-button'))
expect(mockOnEnroll).toHaveBeenCalledTimes(1) // Should be 1, not 2
})
})

Running Tests
Jalankan test button clicks:
npm run test CourseCard.test.tsx

Pastiin semua test passed. Kalo ada yang gagal, check apakah:
- userEvent.setup() dipanggil sebelum render
- Semua click actions pake await
- Mock functions di-clear antara test kalo perlu
- Button elements punya data-testid yang benar
Tips Penting Testing Button Clicks
Beberapa hal yang perlu diperhatiin:
- Selalu pake
userEventinstead offireEventbuat interaksi yang lebih realistic - Jangan lupa
awaitwaktu pake userEvent methods - Clear atau reset mocks antar test buat isolation
- Test tidak hanya callback terpanggil, tapi juga side effects seperti state changes
- Test edge cases kayak button tanpa callback atau multiple rapid clicks
- Verify button text dan attributes sesuai kondisi
Dengan menguasai testing button clicks, kamu bisa ensure semua interaksi user di aplikasi berfungsi dengan baik. Ini adalah fondasi penting sebelum lanjut ke testing yang lebih kompleks!
Unit Test #5: Testing State Changes
State management adalah jantung dari aplikasi React. Setiap kali state berubah, UI harus update sesuai dengan state baru tersebut. Testing state changes memastiin bahwa logic state management kita berfungsi dengan benar dan UI merespon perubahan state dengan tepat.
Memahami State Testing
Waktu kita test state changes, yang kita verify adalah dua hal: pertama, apakah state benar-benar berubah setelah suatu action? Kedua, apakah UI reflect perubahan state tersebut? Ini penting banget karena bug yang paling umum di React adalah state yang berubah tapi UI gak update, atau sebaliknya.
State testing berbeda dari test rendering biasa karena kita harus trigger action yang mengubah state, lalu verify bahwa perubahan tersebut visible di UI. Kita gak bisa akses state secara langsung dalam test - kita harus verify lewat apa yang user lihat dan alami.
Component dengan State Management
Mari kita bikin component baru yang lebih kompleks dengan multiple state. Buat folder src/components/CourseEnrollment/ dan file CourseEnrollment.types.ts:
export interface CourseEnrollmentProps {
courseTitle: string
price: number
onEnrollmentComplete?: (enrollmentData: EnrollmentData) => void
}
export interface EnrollmentData {
agreed: boolean
paymentMethod: string
enrollmentDate: string
}
Buat component CourseEnrollment.tsx:
import { useState } from 'react'
import { CourseEnrollmentProps, EnrollmentData } from './CourseEnrollment.types'
import './CourseEnrollment.css'
const CourseEnrollment: React.FC<CourseEnrollmentProps> = ({
courseTitle,
price,
onEnrollmentComplete
}) => {
const [step, setStep] = useState(1)
const [agreed, setAgreed] = useState(false)
const [paymentMethod, setPaymentMethod] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const [enrollmentSuccess, setEnrollmentSuccess] = useState(false)
const handleAgreeToggle = () => {
setAgreed(!agreed)
}
const handlePaymentMethodChange = (method: string) => {
setPaymentMethod(method)
}
const handleNextStep = () => {
if (step === 1 && agreed) {
setStep(2)
}
}
const handlePreviousStep = () => {
if (step === 2) {
setStep(1)
}
}
const handleEnrollment = async () => {
if (paymentMethod) {
setIsProcessing(true)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
setIsProcessing(false)
setEnrollmentSuccess(true)
const enrollmentData: EnrollmentData = {
agreed,
paymentMethod,
enrollmentDate: new Date().toISOString()
}
if (onEnrollmentComplete) {
onEnrollmentComplete(enrollmentData)
}
}
}
if (enrollmentSuccess) {
return (
<div className="enrollment-success" data-testid="enrollment-success">
<h2>Enrollment Berhasil!</h2>
<p>Selamat! Kamu berhasil mendaftar ke course {courseTitle}</p>
<p data-testid="success-message">
Silakan cek email untuk informasi lebih lanjut
</p>
</div>
)
}
return (
<div className="course-enrollment" data-testid="course-enrollment">
<h2>Daftar Course: {courseTitle}</h2>
<p className="course-price" data-testid="course-price">
Harga: Rp {price.toLocaleString('id-ID')}
</p>
<div className="enrollment-steps" data-testid="enrollment-steps">
<div className={`step ${step === 1 ? 'active' : ''}`}>Step 1</div>
<div className={`step ${step === 2 ? 'active' : ''}`}>Step 2</div>
</div>
{step === 1 && (
<div className="step-content" data-testid="step-1">
<h3>Syarat dan Ketentuan</h3>
<p>Silakan baca dan setujui syarat dan ketentuan berikut:</p>
<div className="agreement-section">
<label>
<input
type="checkbox"
checked={agreed}
onChange={handleAgreeToggle}
data-testid="agreement-checkbox"
/>
Saya setuju dengan syarat dan ketentuan BuildWithAngga
</label>
</div>
<button
onClick={handleNextStep}
disabled={!agreed}
data-testid="next-button"
className="btn-primary"
>
Lanjut ke Pembayaran
</button>
</div>
)}
{step === 2 && (
<div className="step-content" data-testid="step-2">
<h3>Pilih Metode Pembayaran</h3>
<div className="payment-methods">
<label className="payment-option">
<input
type="radio"
name="payment"
value="credit-card"
checked={paymentMethod === 'credit-card'}
onChange={(e) => handlePaymentMethodChange(e.target.value)}
data-testid="payment-credit-card"
/>
Kartu Kredit
</label>
<label className="payment-option">
<input
type="radio"
name="payment"
value="bank-transfer"
checked={paymentMethod === 'bank-transfer'}
onChange={(e) => handlePaymentMethodChange(e.target.value)}
data-testid="payment-bank-transfer"
/>
Transfer Bank
</label>
<label className="payment-option">
<input
type="radio"
name="payment"
value="e-wallet"
checked={paymentMethod === 'e-wallet'}
onChange={(e) => handlePaymentMethodChange(e.target.value)}
data-testid="payment-e-wallet"
/>
E-Wallet
</label>
</div>
{paymentMethod && (
<div className="selected-payment" data-testid="selected-payment">
Metode dipilih: {paymentMethod}
</div>
)}
<div className="step-actions">
<button
onClick={handlePreviousStep}
data-testid="back-button"
className="btn-secondary"
>
Kembali
</button>
<button
onClick={handleEnrollment}
disabled={!paymentMethod || isProcessing}
data-testid="enroll-button"
className="btn-primary"
>
{isProcessing ? 'Memproses...' : 'Daftar Sekarang'}
</button>
</div>
</div>
)}
</div>
)
}
export default CourseEnrollment
Sekarang buat file styling CourseEnrollment.css:
.course-enrollment {
max-width: 600px;
margin: 0 auto;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.course-enrollment h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
color: #111827;
}
.course-price {
font-size: 20px;
font-weight: 700;
color: #10b981;
margin-bottom: 24px;
}
.enrollment-steps {
display: flex;
gap: 16px;
margin-bottom: 32px;
}
.enrollment-steps .step {
flex: 1;
padding: 12px;
text-align: center;
background: #f3f4f6;
border-radius: 8px;
font-weight: 600;
color: #6b7280;
transition: all 0.3s;
}
.enrollment-steps .step.active {
background: #3b82f6;
color: white;
}
.step-content {
margin-bottom: 24px;
}
.step-content h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #1f2937;
}
.agreement-section {
margin: 24px 0;
padding: 16px;
background: #f9fafb;
border-radius: 8px;
}
.agreement-section label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
font-size: 14px;
}
.agreement-section input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.payment-methods {
display: flex;
flex-direction: column;
gap: 12px;
margin: 24px 0;
}
.payment-option {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.payment-option:hover {
border-color: #3b82f6;
background: #f0f9ff;
}
.payment-option input[type="radio"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.selected-payment {
padding: 12px 16px;
background: #dbeafe;
border-left: 4px solid #3b82f6;
border-radius: 4px;
font-weight: 600;
color: #1e40af;
margin-bottom: 24px;
}
.btn-primary {
padding: 12px 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.btn-secondary {
padding: 12px 24px;
background: white;
color: #3b82f6;
border: 2px solid #3b82f6;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: #f0f9ff;
}
.step-actions {
display: flex;
gap: 12px;
justify-content: space-between;
}
.enrollment-success {
max-width: 600px;
margin: 0 auto;
padding: 48px 24px;
text-align: center;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.enrollment-success h2 {
font-size: 28px;
font-weight: 700;
color: #10b981;
margin-bottom: 16px;
}
.enrollment-success p {
font-size: 16px;
color: #6b7280;
line-height: 1.6;
margin-bottom: 8px;
}
Testing Toggle State
Mari kita mulai dengan test toggle functionality yang paling sederhana. Buat file CourseEnrollment.test.tsx:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import CourseEnrollment from './CourseEnrollment'
describe('CourseEnrollment State Changes', () => {
const defaultProps = {
courseTitle: 'Mastering React Testing',
price: 399000
}
it('should toggle agreement checkbox state', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
const checkbox = screen.getByTestId('agreement-checkbox')
// Initial state - unchecked
expect(checkbox).not.toBeChecked()
// Click to check
await user.click(checkbox)
expect(checkbox).toBeChecked()
// Click again to uncheck
await user.click(checkbox)
expect(checkbox).not.toBeChecked()
})
it('should enable next button when agreement is checked', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
const checkbox = screen.getByTestId('agreement-checkbox')
const nextButton = screen.getByTestId('next-button')
// Button should be disabled initially
expect(nextButton).toBeDisabled()
// Check the agreement
await user.click(checkbox)
// Button should be enabled now
expect(nextButton).toBeEnabled()
})
it('should disable next button when agreement is unchecked', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
const checkbox = screen.getByTestId('agreement-checkbox')
const nextButton = screen.getByTestId('next-button')
// Check then uncheck
await user.click(checkbox)
expect(nextButton).toBeEnabled()
await user.click(checkbox)
expect(nextButton).toBeDisabled()
})
})

Testing Step Navigation State
Sekarang test perubahan step dalam enrollment process:
describe('CourseEnrollment Step Navigation', () => {
const defaultProps = {
courseTitle: 'Advanced TypeScript',
price: 450000
}
it('should change to step 2 when next button clicked', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
// Initially on step 1
expect(screen.getByTestId('step-1')).toBeInTheDocument()
expect(screen.queryByTestId('step-2')).not.toBeInTheDocument()
// Agree and click next
await user.click(screen.getByTestId('agreement-checkbox'))
await user.click(screen.getByTestId('next-button'))
// Should now be on step 2
expect(screen.queryByTestId('step-1')).not.toBeInTheDocument()
expect(screen.getByTestId('step-2')).toBeInTheDocument()
})
it('should go back to step 1 when back button clicked', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
// Go to step 2
await user.click(screen.getByTestId('agreement-checkbox'))
await user.click(screen.getByTestId('next-button'))
expect(screen.getByTestId('step-2')).toBeInTheDocument()
// Click back
await user.click(screen.getByTestId('back-button'))
// Should be back on step 1
expect(screen.getByTestId('step-1')).toBeInTheDocument()
expect(screen.queryByTestId('step-2')).not.toBeInTheDocument()
})
it('should maintain agreement state when navigating between steps', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
const checkbox = screen.getByTestId('agreement-checkbox')
// Check agreement
await user.click(checkbox)
expect(checkbox).toBeChecked()
// Go to step 2 and back
await user.click(screen.getByTestId('next-button'))
await user.click(screen.getByTestId('back-button'))
// Agreement should still be checked
expect(screen.getByTestId('agreement-checkbox')).toBeChecked()
})
})

Testing Radio Button State
Payment method selection menggunakan radio buttons dengan state management:
describe('CourseEnrollment Payment Method State', () => {
const defaultProps = {
courseTitle: 'Node.js Backend Development',
price: 550000
}
const navigateToStep2 = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByTestId('agreement-checkbox'))
await user.click(screen.getByTestId('next-button'))
}
it('should update payment method state when radio selected', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
await navigateToStep2(user)
const creditCardRadio = screen.getByTestId('payment-credit-card')
const bankTransferRadio = screen.getByTestId('payment-bank-transfer')
// Select credit card
await user.click(creditCardRadio)
expect(creditCardRadio).toBeChecked()
expect(bankTransferRadio).not.toBeChecked()
// Change to bank transfer
await user.click(bankTransferRadio)
expect(bankTransferRadio).toBeChecked()
expect(creditCardRadio).not.toBeChecked()
})
it('should show selected payment method text', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
await navigateToStep2(user)
// Initially no payment selected
expect(screen.queryByTestId('selected-payment')).not.toBeInTheDocument()
// Select payment method
await user.click(screen.getByTestId('payment-e-wallet'))
// Should show selected payment
const selectedPayment = screen.getByTestId('selected-payment')
expect(selectedPayment).toBeInTheDocument()
expect(selectedPayment).toHaveTextContent('Metode dipilih: e-wallet')
})
it('should enable enroll button when payment method selected', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
await navigateToStep2(user)
const enrollButton = screen.getByTestId('enroll-button')
// Initially disabled
expect(enrollButton).toBeDisabled()
// Select payment
await user.click(screen.getByTestId('payment-credit-card'))
// Should be enabled
expect(enrollButton).toBeEnabled()
})
})

Testing Processing State
Test loading/processing state yang muncul saat submit:
describe('CourseEnrollment Processing State', () => {
const defaultProps = {
courseTitle: 'Full Stack JavaScript',
price: 650000
}
const completeEnrollmentForm = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByTestId('agreement-checkbox'))
await user.click(screen.getByTestId('next-button'))
await user.click(screen.getByTestId('payment-credit-card'))
}
it('should show processing state when enroll button clicked', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
await completeEnrollmentForm(user)
const enrollButton = screen.getByTestId('enroll-button')
expect(enrollButton).toHaveTextContent('Daftar Sekarang')
await user.click(enrollButton)
// Should show processing state
expect(enrollButton).toHaveTextContent('Memproses...')
expect(enrollButton).toBeDisabled()
})
it('should show success message after enrollment completes', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
await completeEnrollmentForm(user)
await user.click(screen.getByTestId('enroll-button'))
// Wait for success message
const successMessage = await screen.findByTestId('enrollment-success')
expect(successMessage).toBeInTheDocument()
expect(screen.getByTestId('success-message')).toBeInTheDocument()
})
it('should hide enrollment form after success', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
await completeEnrollmentForm(user)
await user.click(screen.getByTestId('enroll-button'))
// Wait for success
await screen.findByTestId('enrollment-success')
// Form should be hidden
expect(screen.queryByTestId('course-enrollment')).not.toBeInTheDocument()
})
})

Testing Complex State Interactions
Test kombinasi dari berbagai state changes:
describe('CourseEnrollment Complex State Interactions', () => {
const defaultProps = {
courseTitle: 'React Native Mobile Development',
price: 750000
}
it('should maintain all states through complete enrollment flow', async () => {
const mockOnComplete = vi.fn()
const user = userEvent.setup()
render(
<CourseEnrollment
{...defaultProps}
onEnrollmentComplete={mockOnComplete}
/>
)
// Step 1: Agreement
const checkbox = screen.getByTestId('agreement-checkbox')
await user.click(checkbox)
expect(checkbox).toBeChecked()
// Navigate to step 2
await user.click(screen.getByTestId('next-button'))
expect(screen.getByTestId('step-2')).toBeInTheDocument()
// Select payment
await user.click(screen.getByTestId('payment-bank-transfer'))
expect(screen.getByTestId('payment-bank-transfer')).toBeChecked()
// Complete enrollment
await user.click(screen.getByTestId('enroll-button'))
// Verify callback called with correct data
await screen.findByTestId('enrollment-success')
expect(mockOnComplete).toHaveBeenCalled()
const callArgs = mockOnComplete.mock.calls[0][0]
expect(callArgs.agreed).toBe(true)
expect(callArgs.paymentMethod).toBe('bank-transfer')
})
it('should reset to correct state when navigating back and forth', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
// Go to step 2
await user.click(screen.getByTestId('agreement-checkbox'))
await user.click(screen.getByTestId('next-button'))
// Select payment
await user.click(screen.getByTestId('payment-e-wallet'))
expect(screen.getByTestId('selected-payment')).toBeInTheDocument()
// Go back
await user.click(screen.getByTestId('back-button'))
expect(screen.getByTestId('step-1')).toBeInTheDocument()
// Go forward again
await user.click(screen.getByTestId('next-button'))
// Payment selection should be maintained
expect(screen.getByTestId('payment-e-wallet')).toBeChecked()
expect(screen.getByTestId('selected-payment')).toBeInTheDocument()
})
})

Testing State with Async Operations
Test state changes yang melibatkan operasi async:
describe('CourseEnrollment Async State Changes', () => {
const defaultProps = {
courseTitle: 'Vue.js Complete Guide',
price: 425000
}
it('should handle state during async enrollment process', async () => {
const user = userEvent.setup()
render(<CourseEnrollment {...defaultProps} />)
// Complete form
await user.click(screen.getByTestId('agreement-checkbox'))
await user.click(screen.getByTestId('next-button'))
await user.click(screen.getByTestId('payment-credit-card'))
const enrollButton = screen.getByTestId('enroll-button')
await user.click(enrollButton)
// During processing
expect(enrollButton).toHaveTextContent('Memproses...')
expect(enrollButton).toBeDisabled()
// After completion
await screen.findByTestId('enrollment-success')
expect(screen.getByText(/Enrollment Berhasil/i)).toBeInTheDocument()
})
})
Running Tests
Jalankan semua test state changes:
npm run test CourseEnrollment.test.tsx

Pastiin semua test passed. State testing biasanya lebih tricky karena melibatkan timing dan sequence of events yang harus tepat.
Best Practices Testing State
Tips penting waktu test state changes:
- Test initial state sebelum ada interaction
- Verify UI reflects state changes dengan tepat
- Test state persistence waktu navigasi antar views
- Gunakan
findByuntuk async state changes - Test edge cases kayak rapid state changes
- Verify side effects dari state changes seperti button disabled/enabled
- Clear state antar test buat proper isolation
- Test kombinasi state changes, bukan cuma individual
Dengan menguasai testing state changes, kamu bisa ensure aplikasi React kamu handle state dengan reliable dan UI selalu sync dengan state yang ada. Skill ini crucial buat build aplikasi yang robust dan maintainable!
Unit Test #6: Testing Form Input
Form adalah salah satu bagian terpenting dalam aplikasi web modern. Hampir semua aplikasi pasti punya form - dari login sederhana sampe formulir kompleks kayak enrollment. Testing form input dengan benar memastiin user bisa interact dengan aplikasi tanpa masalah dan data yang diinput tersimpan dengan tepat.
Pentingnya Testing Form Input
Form testing itu critical karena form adalah pintu masuk data ke aplikasi. Kalo form gak berfungsi dengan baik, user gak bisa submit data, dan bisnis logic aplikasi kita gak jalan. Bayangkan kalo form pendaftaran course BuildWithAngga error - calon siswa gak bisa daftar dan kita kehilangan potential revenue.
Yang perlu kita test dalam form bukan cuma apakah input bisa diketik, tapi juga apakah value tersimpan dengan benar, apakah validation berfungsi, dan apakah form bisa di-submit dengan data yang valid. Kita harus simulasi user behavior sereal mungkin.
Membuat Component CourseSearchForm
Mari kita bikin component form yang lebih kompleks dengan berbagai jenis input. Buat folder src/components/CourseSearchForm/ dan file CourseSearchForm.types.ts:
export interface CourseSearchFormProps {
onSearch: (formData: SearchFormData) => void
onReset?: () => void
}
export interface SearchFormData {
keyword: string
category: string
level: string
priceRange: string
sortBy: string
}
Buat component CourseSearchForm.tsx:
import { useState } from 'react'
import { CourseSearchFormProps, SearchFormData } from './CourseSearchForm.types'
import './CourseSearchForm.css'
const CourseSearchForm: React.FC<CourseSearchFormProps> = ({
onSearch,
onReset
}) => {
const [formData, setFormData] = useState<SearchFormData>({
keyword: '',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
const handleInputChange = (field: keyof SearchFormData, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSearch(formData)
}
const handleReset = () => {
setFormData({
keyword: '',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
if (onReset) {
onReset()
}
}
return (
<form
className="course-search-form"
onSubmit={handleSubmit}
data-testid="search-form"
>
<h2>Cari Course BuildWithAngga</h2>
<div className="form-group">
<label htmlFor="keyword">Kata Kunci</label>
<input
id="keyword"
type="text"
value={formData.keyword}
onChange={(e) => handleInputChange('keyword', e.target.value)}
placeholder="Masukkan kata kunci course..."
data-testid="keyword-input"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="category">Kategori</label>
<input
id="category"
type="text"
value={formData.category}
onChange={(e) => handleInputChange('category', e.target.value)}
placeholder="Frontend, Backend, Mobile..."
data-testid="category-input"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="level">Tingkat Kesulitan</label>
<select
id="level"
value={formData.level}
onChange={(e) => handleInputChange('level', e.target.value)}
data-testid="level-select"
className="form-select"
>
<option value="">Semua Level</option>
<option value="beginner">Pemula</option>
<option value="intermediate">Menengah</option>
<option value="advanced">Lanjutan</option>
</select>
</div>
<div className="form-group">
<label>Rentang Harga</label>
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="all"
checked={formData.priceRange === 'all'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-all"
/>
Semua Harga
</label>
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="free"
checked={formData.priceRange === 'free'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-free"
/>
Gratis
</label>
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="paid"
checked={formData.priceRange === 'paid'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-paid"
/>
Berbayar
</label>
</div>
</div>
<div className="form-group">
<label htmlFor="sortBy">Urutkan</label>
<select
id="sortBy"
value={formData.sortBy}
onChange={(e) => handleInputChange('sortBy', e.target.value)}
data-testid="sort-select"
className="form-select"
>
<option value="newest">Terbaru</option>
<option value="popular">Terpopuler</option>
<option value="rating">Rating Tertinggi</option>
<option value="price-low">Harga Terendah</option>
<option value="price-high">Harga Tertinggi</option>
</select>
</div>
<div className="form-actions">
<button
type="submit"
data-testid="submit-button"
className="btn-primary"
>
Cari Course
</button>
<button
type="button"
onClick={handleReset}
data-testid="reset-button"
className="btn-secondary"
>
Reset Filter
</button>
</div>
</form>
)
}
export default CourseSearchForm
Buat file styling CourseSearchForm.css:
.course-search-form {
max-width: 600px;
margin: 0 auto;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.course-search-form h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 24px;
color: #111827;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #374151;
}
.form-input,
.form-select {
width: 100%;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-input:focus,
.form-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-input::placeholder {
color: #9ca3af;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 400;
}
.radio-label input[type="radio"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 24px;
}
.btn-primary {
flex: 1;
padding: 12px 24px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
padding: 12px 24px;
background: white;
color: #6b7280;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: #f9fafb;
border-color: #9ca3af;
}
Testing Text Input
Mari kita mulai test input text yang paling dasar. Buat file CourseSearchForm.test.tsx:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import CourseSearchForm from './CourseSearchForm'
describe('CourseSearchForm Text Input', () => {
it('should update keyword input when user types', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Type in the input
await user.type(keywordInput, 'React Hooks')
// Verify input value changed
expect(keywordInput).toHaveValue('React Hooks')
})
it('should update category input when user types', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const categoryInput = screen.getByTestId('category-input')
await user.type(categoryInput, 'Frontend Development')
expect(categoryInput).toHaveValue('Frontend Development')
})
it('should handle typing multiple characters', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Type character by character
await user.type(keywordInput, 'JavaScript')
expect(keywordInput).toHaveValue('JavaScript')
})
it('should allow clearing input by selecting all and deleting', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Type some text
await user.type(keywordInput, 'TypeScript')
expect(keywordInput).toHaveValue('TypeScript')
// Select all and delete
await user.clear(keywordInput)
expect(keywordInput).toHaveValue('')
})
})

Testing Select Dropdown
Test untuk dropdown/select element yang punya multiple options:
describe('CourseSearchForm Select Input', () => {
it('should update level select when option is chosen', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const levelSelect = screen.getByTestId('level-select')
// Initially empty
expect(levelSelect).toHaveValue('')
// Select an option
await user.selectOptions(levelSelect, 'beginner')
expect(levelSelect).toHaveValue('beginner')
})
it('should change sort option correctly', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const sortSelect = screen.getByTestId('sort-select')
// Default value
expect(sortSelect).toHaveValue('newest')
// Change to popular
await user.selectOptions(sortSelect, 'popular')
expect(sortSelect).toHaveValue('popular')
// Change to rating
await user.selectOptions(sortSelect, 'rating')
expect(sortSelect).toHaveValue('rating')
})
it('should handle all level options', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const levelSelect = screen.getByTestId('level-select')
// Test each option
const levels = ['beginner', 'intermediate', 'advanced']
for (const level of levels) {
await user.selectOptions(levelSelect, level)
expect(levelSelect).toHaveValue(level)
}
})
})

Testing Radio Buttons
Radio button adalah form control yang membutuhkan handling berbeda:
describe('CourseSearchForm Radio Input', () => {
it('should select radio button when clicked', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const allRadio = screen.getByTestId('price-all')
const freeRadio = screen.getByTestId('price-free')
const paidRadio = screen.getByTestId('price-paid')
// Initially 'all' is selected
expect(allRadio).toBeChecked()
expect(freeRadio).not.toBeChecked()
expect(paidRadio).not.toBeChecked()
// Click free radio
await user.click(freeRadio)
expect(freeRadio).toBeChecked()
expect(allRadio).not.toBeChecked()
expect(paidRadio).not.toBeChecked()
})
it('should change radio selection correctly', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const freeRadio = screen.getByTestId('price-free')
const paidRadio = screen.getByTestId('price-paid')
// Select free
await user.click(freeRadio)
expect(freeRadio).toBeChecked()
// Change to paid
await user.click(paidRadio)
expect(paidRadio).toBeChecked()
expect(freeRadio).not.toBeChecked()
})
it('should only allow one radio button selected at a time', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const allRadio = screen.getByTestId('price-all')
const freeRadio = screen.getByTestId('price-free')
const paidRadio = screen.getByTestId('price-paid')
// Click through all options
await user.click(freeRadio)
expect(freeRadio).toBeChecked()
expect(allRadio).not.toBeChecked()
expect(paidRadio).not.toBeChecked()
await user.click(paidRadio)
expect(paidRadio).toBeChecked()
expect(freeRadio).not.toBeChecked()
expect(allRadio).not.toBeChecked()
})
})

Testing Complete Form Interaction
Test scenario dimana user mengisi semua field dalam form:
describe('CourseSearchForm Complete Form Interaction', () => {
it('should handle filling all form fields', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Fill text inputs
await user.type(screen.getByTestId('keyword-input'), 'Next.js Tutorial')
await user.type(screen.getByTestId('category-input'), 'Web Development')
// Select dropdown options
await user.selectOptions(screen.getByTestId('level-select'), 'intermediate')
await user.selectOptions(screen.getByTestId('sort-select'), 'popular')
// Select radio button
await user.click(screen.getByTestId('price-paid'))
// Verify all values
expect(screen.getByTestId('keyword-input')).toHaveValue('Next.js Tutorial')
expect(screen.getByTestId('category-input')).toHaveValue('Web Development')
expect(screen.getByTestId('level-select')).toHaveValue('intermediate')
expect(screen.getByTestId('sort-select')).toHaveValue('popular')
expect(screen.getByTestId('price-paid')).toBeChecked()
})
it('should submit form with correct data', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Fill form
await user.type(screen.getByTestId('keyword-input'), 'React')
await user.type(screen.getByTestId('category-input'), 'Frontend')
await user.selectOptions(screen.getByTestId('level-select'), 'beginner')
await user.click(screen.getByTestId('price-free'))
await user.selectOptions(screen.getByTestId('sort-select'), 'rating')
// Submit form
await user.click(screen.getByTestId('submit-button'))
// Verify onSearch called with correct data
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'React',
category: 'Frontend',
level: 'beginner',
priceRange: 'free',
sortBy: 'rating'
})
})
})

Testing Form Reset
Test fungsi reset form yang clear semua input:
describe('CourseSearchForm Reset Functionality', () => {
it('should reset all form fields to initial values', async () => {
const mockOnSearch = vi.fn()
const mockOnReset = vi.fn()
const user = userEvent.setup()
render(
<CourseSearchForm
onSearch={mockOnSearch}
onReset={mockOnReset}
/>
)
// Fill form with data
await user.type(screen.getByTestId('keyword-input'), 'Vue.js')
await user.type(screen.getByTestId('category-input'), 'Frontend')
await user.selectOptions(screen.getByTestId('level-select'), 'advanced')
await user.click(screen.getByTestId('price-paid'))
await user.selectOptions(screen.getByTestId('sort-select'), 'price-high')
// Click reset
await user.click(screen.getByTestId('reset-button'))
// Verify all fields reset
expect(screen.getByTestId('keyword-input')).toHaveValue('')
expect(screen.getByTestId('category-input')).toHaveValue('')
expect(screen.getByTestId('level-select')).toHaveValue('')
expect(screen.getByTestId('price-all')).toBeChecked()
expect(screen.getByTestId('sort-select')).toHaveValue('newest')
expect(mockOnReset).toHaveBeenCalled()
})
it('should allow refilling form after reset', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Fill, reset, then fill again
await user.type(screen.getByTestId('keyword-input'), 'First')
await user.click(screen.getByTestId('reset-button'))
await user.type(screen.getByTestId('keyword-input'), 'Second')
expect(screen.getByTestId('keyword-input')).toHaveValue('Second')
})
})

Testing Special Characters and Edge Cases
Test input dengan karakter special dan edge cases:
describe('CourseSearchForm Edge Cases', () => {
it('should handle special characters in text input', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
await user.type(keywordInput, 'C++ & C# Programming!')
expect(keywordInput).toHaveValue('C++ & C# Programming!')
})
it('should handle very long text input', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const longText = 'A'.repeat(100)
const keywordInput = screen.getByTestId('keyword-input')
await user.type(keywordInput, longText)
expect(keywordInput).toHaveValue(longText)
})
it('should handle numbers in text input', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
await user.type(keywordInput, 'HTML5 CSS3 ES6')
expect(keywordInput).toHaveValue('HTML5 CSS3 ES6')
})
it('should handle empty form submission', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Submit without filling anything
await user.click(screen.getByTestId('submit-button'))
// Should still call onSearch with empty values
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: '',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
})
})

Testing Rapid Input Changes
Test performa form dengan rapid input changes:
describe('CourseSearchForm Rapid Changes', () => {
it('should handle rapid typing correctly', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Rapid typing
await user.type(keywordInput, 'FastTyping')
expect(keywordInput).toHaveValue('FastTyping')
})
it('should handle rapid select changes', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const levelSelect = screen.getByTestId('level-select')
// Rapid changes
await user.selectOptions(levelSelect, 'beginner')
await user.selectOptions(levelSelect, 'intermediate')
await user.selectOptions(levelSelect, 'advanced')
expect(levelSelect).toHaveValue('advanced')
})
it('should handle rapid radio button clicks', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Click multiple times rapidly
await user.click(screen.getByTestId('price-free'))
await user.click(screen.getByTestId('price-paid'))
await user.click(screen.getByTestId('price-all'))
expect(screen.getByTestId('price-all')).toBeChecked()
})
})

Running Tests
Jalankan test form input:
npm run test CourseSearchForm.test.tsx

Pastiin semua test passed dan form handling berfungsi dengan baik dalam berbagai scenario.
Best Practices Testing Form Input
Tips penting waktu test form input:
- Gunakan
userEvent.type()instead of mengset value langsung - ini simulate user typing - Test dengan
userEvent.selectOptions()buat dropdown select - Verify not only value changes tapi juga checked status untuk radio/checkbox
- Test form submission dengan berbagai kombinasi input
- Test reset functionality buat ensure form bisa di-clear
- Test edge cases kayak special characters, panjang maksimal, atau empty values
- Gunakan
userEvent.clear()buat test clearing input - Test rapid changes buat ensure form tetep stable
Dengan menguasai testing form input, kamu bisa ensure user experience yang smooth waktu mengisi form. Form yang reliable adalah kunci kepuasan user, apalagi buat aplikasi bisnis kayak BuildWithAngga!
Unit Test #7: Testing Form Validation
Form validation adalah salah satu aspek terpenting dalam aplikasi web. Validasi yang baik mencegah user submit data yang salah atau tidak lengkap, dan memberikan feedback yang jelas agar user tau apa yang harus diperbaiki. Testing validation memastikan bahwa logic validasi berfungsi dengan benar dan error messages tampil pada waktu yang tepat.
Mengapa Validation Testing Penting
Bayangkan kalo form pendaftaran course BuildWithAngga gak punya validasi - user bisa submit form dengan email kosong, password cuma 2 karakter, atau nama yang gak valid. Data yang masuk ke database jadi berantakan dan bikin masalah di kemudian hari. Validation adalah garis pertahanan pertama buat ensure data quality.
Testing validation gak cuma ngecek apakah error message muncul, tapi juga memastikan error hilang waktu user sudah perbaiki inputnya. UX yang baik adalah error message yang informatif dan disappear waktu masalah sudah fixed. Kita juga harus test bahwa form gak bisa di-submit kalo ada validation error.
Update CourseSearchForm dengan Validation
Mari kita update component CourseSearchForm buat include validation logic. Update file CourseSearchForm.tsx:
import { useState } from 'react'
import { CourseSearchFormProps, SearchFormData } from './CourseSearchForm.types'
import './CourseSearchForm.css'
const CourseSearchForm: React.FC<CourseSearchFormProps> = ({
onSearch,
onReset
}) => {
const [formData, setFormData] = useState<SearchFormData>({
keyword: '',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [touched, setTouched] = useState<Record<string, boolean>>({})
const validateKeyword = (value: string): string => {
if (value.trim().length === 0) {
return 'Kata kunci tidak boleh kosong'
}
if (value.trim().length < 3) {
return 'Kata kunci minimal 3 karakter'
}
if (value.trim().length > 50) {
return 'Kata kunci maksimal 50 karakter'
}
return ''
}
const validateCategory = (value: string): string => {
if (value.trim().length > 0 && value.trim().length < 2) {
return 'Kategori minimal 2 karakter'
}
return ''
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
const keywordError = validateKeyword(formData.keyword)
if (keywordError) {
newErrors.keyword = keywordError
}
const categoryError = validateCategory(formData.category)
if (categoryError) {
newErrors.category = categoryError
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleInputChange = (field: keyof SearchFormData, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}))
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
const handleBlur = (field: string) => {
setTouched(prev => ({
...prev,
[field]: true
}))
// Validate on blur
if (field === 'keyword') {
const error = validateKeyword(formData.keyword)
if (error) {
setErrors(prev => ({ ...prev, keyword: error }))
}
} else if (field === 'category') {
const error = validateCategory(formData.category)
if (error) {
setErrors(prev => ({ ...prev, category: error }))
}
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Mark all fields as touched
setTouched({
keyword: true,
category: true
})
if (!validateForm()) {
return
}
onSearch(formData)
}
const handleReset = () => {
setFormData({
keyword: '',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
setErrors({})
setTouched({})
if (onReset) {
onReset()
}
}
return (
<form
className="course-search-form"
onSubmit={handleSubmit}
data-testid="search-form"
>
<h2>Cari Course BuildWithAngga</h2>
<div className="form-group">
<label htmlFor="keyword">
Kata Kunci <span className="required">*</span>
</label>
<input
id="keyword"
type="text"
value={formData.keyword}
onChange={(e) => handleInputChange('keyword', e.target.value)}
onBlur={() => handleBlur('keyword')}
placeholder="Masukkan kata kunci course..."
data-testid="keyword-input"
className={`form-input ${errors.keyword ? 'error' : ''}`}
aria-invalid={!!errors.keyword}
aria-describedby={errors.keyword ? 'keyword-error' : undefined}
/>
{errors.keyword && touched.keyword && (
<span
id="keyword-error"
className="error-message"
data-testid="keyword-error"
role="alert"
>
{errors.keyword}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="category">Kategori</label>
<input
id="category"
type="text"
value={formData.category}
onChange={(e) => handleInputChange('category', e.target.value)}
onBlur={() => handleBlur('category')}
placeholder="Frontend, Backend, Mobile..."
data-testid="category-input"
className={`form-input ${errors.category ? 'error' : ''}`}
aria-invalid={!!errors.category}
aria-describedby={errors.category ? 'category-error' : undefined}
/>
{errors.category && touched.category && (
<span
id="category-error"
className="error-message"
data-testid="category-error"
role="alert"
>
{errors.category}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="level">Tingkat Kesulitan</label>
<select
id="level"
value={formData.level}
onChange={(e) => handleInputChange('level', e.target.value)}
data-testid="level-select"
className="form-select"
>
<option value="">Semua Level</option>
<option value="beginner">Pemula</option>
<option value="intermediate">Menengah</option>
<option value="advanced">Lanjutan</option>
</select>
</div>
<div className="form-group">
<label>Rentang Harga</label>
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="all"
checked={formData.priceRange === 'all'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-all"
/>
Semua Harga
</label>
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="free"
checked={formData.priceRange === 'free'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-free"
/>
Gratis
</label>
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="paid"
checked={formData.priceRange === 'paid'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-paid"
/>
Berbayar
</label>
</div>
</div>
<div className="form-group">
<label htmlFor="sortBy">Urutkan</label>
<select
id="sortBy"
value={formData.sortBy}
onChange={(e) => handleInputChange('sortBy', e.target.value)}
data-testid="sort-select"
className="form-select"
>
<option value="newest">Terbaru</option>
<option value="popular">Terpopuler</option>
<option value="rating">Rating Tertinggi</option>
<option value="price-low">Harga Terendah</option>
<option value="price-high">Harga Tertinggi</option>
</select>
</div>
<div className="form-actions">
<button
type="submit"
data-testid="submit-button"
className="btn-primary"
>
Cari Course
</button>
<button
type="button"
onClick={handleReset}
data-testid="reset-button"
className="btn-secondary"
>
Reset Filter
</button>
</div>
</form>
)
}
export default CourseSearchForm
Update CSS buat error states di CourseSearchForm.css:
/* Tambahkan styling ini ke file yang sudah ada */
.form-input.error,
.form-select.error {
border-color: #ef4444;
}
.form-input.error:focus,
.form-select.error:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.error-message {
display: block;
margin-top: 6px;
font-size: 13px;
color: #ef4444;
font-weight: 500;
}
.required {
color: #ef4444;
font-weight: 700;
}
Testing Validation Error Messages
Sekarang kita test apakah error messages muncul dengan benar. Tambahkan test di CourseSearchForm.test.tsx:
describe('CourseSearchForm Validation', () => {
it('should show error when keyword is empty and form submitted', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Submit without filling keyword
await user.click(screen.getByTestId('submit-button'))
// Error should appear
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
expect(screen.getByTestId('keyword-error')).toHaveTextContent(
'Kata kunci tidak boleh kosong'
)
// onSearch should not be called
expect(mockOnSearch).not.toHaveBeenCalled()
})
it('should show error when keyword is less than 3 characters', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Type only 2 characters
await user.type(keywordInput, 'Re')
await user.click(screen.getByTestId('submit-button'))
// Error should appear
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
expect(screen.getByTestId('keyword-error')).toHaveTextContent(
'Kata kunci minimal 3 karakter'
)
expect(mockOnSearch).not.toHaveBeenCalled()
})
it('should show error when keyword exceeds 50 characters', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
const longKeyword = 'A'.repeat(51)
await user.type(keywordInput, longKeyword)
await user.click(screen.getByTestId('submit-button'))
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
expect(screen.getByTestId('keyword-error')).toHaveTextContent(
'Kata kunci maksimal 50 karakter'
)
expect(mockOnSearch).not.toHaveBeenCalled()
})
it('should show error when category is only 1 character', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'React')
await user.type(screen.getByTestId('category-input'), 'F')
await user.click(screen.getByTestId('submit-button'))
expect(screen.getByTestId('category-error')).toBeInTheDocument()
expect(screen.getByTestId('category-error')).toHaveTextContent(
'Kategori minimal 2 karakter'
)
expect(mockOnSearch).not.toHaveBeenCalled()
})
})

Testing Error Clearing
Test apakah error hilang waktu user perbaiki input:
describe('CourseSearchForm Error Clearing', () => {
it('should clear error when user starts typing valid input', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Trigger error
await act(async () => {
await user.type(keywordInput, 'Re')
await user.click(screen.getByTestId('submit-button'))
})
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
// Type more to make it valid
await act(async () => {
await user.type(keywordInput, 'act')
})
await waitFor(() => {
expect(screen.queryByTestId('keyword-error')).not.toBeInTheDocument()
})
})
it('should clear all errors when reset button clicked', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Submit to trigger errors
await act(async () => {
await user.click(screen.getByTestId('submit-button'))
})
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
// Click reset
await act(async () => {
await user.click(screen.getByTestId('reset-button'))
})
await waitFor(() => {
expect(screen.queryByTestId('keyword-error')).not.toBeInTheDocument()
expect(screen.queryByTestId('category-error')).not.toBeInTheDocument()
})
})
it('should clear error on blur when input becomes valid', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Type invalid input and blur
await act(async () => {
await user.type(keywordInput, 'Re')
await user.tab()
})
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
// Focus again and fix the input
await act(async () => {
keywordInput.focus()
await user.clear(keywordInput)
await user.type(keywordInput, 'React Hooks')
await user.tab()
})
await waitFor(() => {
expect(screen.queryByTestId('keyword-error')).not.toBeInTheDocument()
})
})
})

Testing Validation on Blur
Test validasi yang trigger waktu user blur dari input field:
describe('CourseSearchForm Validation on Blur', () => {
it('should show error on blur with invalid keyword', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Type invalid input
await user.type(keywordInput, 'Re')
// Blur the input
await user.tab()
// Error should appear
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
})
it('should not show error on blur with valid keyword', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Type valid input
await user.type(keywordInput, 'React Testing')
await user.tab()
// Error should not appear
expect(screen.queryByTestId('keyword-error')).not.toBeInTheDocument()
})
it('should validate category on blur', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const categoryInput = screen.getByTestId('category-input')
// Type single character
await user.type(categoryInput, 'F')
await user.tab()
expect(screen.getByTestId('category-error')).toBeInTheDocument()
})
})

Testing Successful Form Submission
Test bahwa form bisa submit waktu semua validation passed:
describe('CourseSearchForm Valid Submission', () => {
it('should submit form when all validations pass', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Fill with valid data
await user.type(screen.getByTestId('keyword-input'), 'React Hooks')
await user.type(screen.getByTestId('category-input'), 'Frontend')
await user.selectOptions(screen.getByTestId('level-select'), 'beginner')
// Submit
await user.click(screen.getByTestId('submit-button'))
// No errors should appear
expect(screen.queryByTestId('keyword-error')).not.toBeInTheDocument()
expect(screen.queryByTestId('category-error')).not.toBeInTheDocument()
// onSearch should be called
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'React Hooks',
category: 'Frontend',
level: 'beginner',
priceRange: 'all',
sortBy: 'newest'
})
})
it('should submit with minimal valid data', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Only fill required field
await user.type(screen.getByTestId('keyword-input'), 'Vue')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'Vue',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
})
it('should allow multiple successful submissions', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// First submission
await user.type(screen.getByTestId('keyword-input'), 'React')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledTimes(1)
// Change and submit again
const keywordInput = screen.getByTestId('keyword-input')
await user.clear(keywordInput)
await user.type(keywordInput, 'Vue.js')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledTimes(2)
})
})

Testing Multiple Validation Errors
Test scenario dimana ada multiple errors sekaligus:
describe('CourseSearchForm Multiple Errors', () => {
it('should show multiple errors when multiple fields invalid', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Fill multiple fields with invalid data
await user.type(screen.getByTestId('keyword-input'), 'Re')
await user.type(screen.getByTestId('category-input'), 'F')
await user.click(screen.getByTestId('submit-button'))
// Both errors should appear
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
expect(screen.getByTestId('category-error')).toBeInTheDocument()
expect(mockOnSearch).not.toHaveBeenCalled()
})
it('should clear errors independently', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'Re')
await user.type(screen.getByTestId('category-input'), 'F')
await user.click(screen.getByTestId('submit-button'))
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
expect(screen.getByTestId('category-error')).toBeInTheDocument()
// Fix keyword only
await user.type(screen.getByTestId('keyword-input'), 'act')
// Keyword error should be cleared but category error remains
expect(screen.queryByTestId('keyword-error')).not.toBeInTheDocument()
expect(screen.getByTestId('category-error')).toBeInTheDocument()
})
it('should allow submission when all errors are fixed', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Trigger errors
await user.type(screen.getByTestId('keyword-input'), 'Re')
await user.type(screen.getByTestId('category-input'), 'F')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).not.toHaveBeenCalled()
// Fix all errors
await user.type(screen.getByTestId('keyword-input'), 'act')
await user.type(screen.getByTestId('category-input'), 'rontend')
await user.click(screen.getByTestId('submit-button'))
// Now should submit successfully
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'React',
category: 'Frontend',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
})
})

Testing Accessibility Attributes
Test bahwa error messages punya accessibility attributes yang proper:
describe('CourseSearchForm Accessibility', () => {
it('should have proper aria attributes when error present', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.click(screen.getByTestId('submit-button'))
const keywordInput = screen.getByTestId('keyword-input')
const errorMessage = screen.getByTestId('keyword-error')
// Check aria attributes
expect(keywordInput).toHaveAttribute('aria-invalid', 'true')
expect(keywordInput).toHaveAttribute('aria-describedby', 'keyword-error')
expect(errorMessage).toHaveAttribute('role', 'alert')
})
it('should remove aria-invalid when error cleared', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
// Trigger error
await user.click(screen.getByTestId('submit-button'))
expect(keywordInput).toHaveAttribute('aria-invalid', 'true')
// Fix error
await user.type(keywordInput, 'React')
// aria-invalid should be false
expect(keywordInput).toHaveAttribute('aria-invalid', 'false')
})
})

Running Tests
Jalankan test validation:
npm run test CourseSearchForm.test.tsx

Pastiin semua validation test passed dan form validation berfungsi dengan baik.
Best Practices Testing Validation
Tips penting waktu test form validation:
- Test semua validation rules yang ada di component
- Test bahwa error messages muncul dengan text yang tepat
- Verify form tidak submit waktu ada validation error
- Test error clearing waktu user perbaiki input
- Test validation trigger pada berbagai events - submit, blur, change
- Test multiple errors bisa muncul dan cleared independently
- Verify accessibility attributes proper - aria-invalid, aria-describedby, role
- Test edge cases kayak spaces, special characters dalam validation
- Test bahwa form bisa submit setelah semua errors fixed
Dengan menguasai validation testing, kamu bisa ensure aplikasi punya data quality yang baik dan user experience yang smooth. User akan appreciate feedback yang clear dan helpful waktu mereka ngisi form!
Unit Test #8: Testing Form Submission
Form submission adalah moment of truth dalam aplikasi - ini adalah waktu dimana semua input user dikumpulkan dan dikirim untuk diproses. Testing form submission yang comprehensive memastiin bahwa data dikirim dengan format yang benar, callback terpanggil dengan parameter yang tepat, dan form behavior sesuai ekspektasi dalam berbagai scenario.
Mengapa Form Submission Testing Critical
Form submission adalah proses yang melibatkan banyak moving parts - validation, state management, data transformation, dan callback execution. Satu error kecil di sini bisa bikin user frustasi karena data mereka gak kekirim atau kekirim dengan format yang salah. Di BuildWithAngga, bayangkan kalo form search course gak kirim data dengan bener - user gak bisa nemuin course yang mereka cari.
Testing submission bukan cuma ngecek apakah onSubmit ke-trigger, tapi juga memastikan data yang dikirim complete, formatted correctly, dan sesuai dengan apa yang user input. Kita juga harus test berbagai kombinasi input buat ensure form robust dalam semua scenario.
Testing Basic Form Submission
Mari kita mulai dengan test submission yang paling sederhana. Tambahkan test di CourseSearchForm.test.tsx:
describe('CourseSearchForm Submission', () => {
it('should submit form with all fields filled', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Fill all form fields
await user.type(screen.getByTestId('keyword-input'), 'React Hooks')
await user.type(screen.getByTestId('category-input'), 'Frontend Development')
await user.selectOptions(screen.getByTestId('level-select'), 'intermediate')
await user.click(screen.getByTestId('price-paid'))
await user.selectOptions(screen.getByTestId('sort-select'), 'popular')
// Submit form
await user.click(screen.getByTestId('submit-button'))
// Verify callback called with correct data
expect(mockOnSearch).toHaveBeenCalledTimes(1)
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'React Hooks',
category: 'Frontend Development',
level: 'intermediate',
priceRange: 'paid',
sortBy: 'popular'
})
})
it('should submit form with only required fields', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Only fill keyword (required field)
await user.type(screen.getByTestId('keyword-input'), 'Vue.js')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'Vue.js',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
})
it('should submit form using Enter key', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
await user.type(keywordInput, 'TypeScript')
// Submit with Enter key
await user.keyboard('{Enter}')
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'TypeScript',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
})
})

Testing Different Input Combinations
Test berbagai kombinasi input buat ensure form handle semua scenario:
describe('CourseSearchForm Input Combinations', () => {
it('should submit with keyword and category only', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'Next.js')
await user.type(screen.getByTestId('category-input'), 'Full Stack')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'Next.js',
category: 'Full Stack',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
})
it('should submit with keyword and level selection', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'JavaScript')
await user.selectOptions(screen.getByTestId('level-select'), 'beginner')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'JavaScript',
category: '',
level: 'beginner',
priceRange: 'all',
sortBy: 'newest'
})
})
it('should submit with free courses filter', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'HTML CSS')
await user.click(screen.getByTestId('price-free'))
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'HTML CSS',
category: '',
level: '',
priceRange: 'free',
sortBy: 'newest'
})
})
it('should submit with custom sort option', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'Python')
await user.selectOptions(screen.getByTestId('sort-select'), 'price-low')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'Python',
category: '',
level: '',
priceRange: 'all',
sortBy: 'price-low'
})
})
})

Testing Multiple Submissions
Test bahwa form bisa di-submit multiple times dengan data berbeda:
describe('CourseSearchForm Multiple Submissions', () => {
it('should handle multiple submissions with different data', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// First submission
await user.type(screen.getByTestId('keyword-input'), 'React')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenNthCalledWith(1, {
keyword: 'React',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
// Clear and submit again with different data
await user.clear(screen.getByTestId('keyword-input'))
await user.type(screen.getByTestId('keyword-input'), 'Angular')
await user.selectOptions(screen.getByTestId('level-select'), 'advanced')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenNthCalledWith(2, {
keyword: 'Angular',
category: '',
level: 'advanced',
priceRange: 'all',
sortBy: 'newest'
})
expect(mockOnSearch).toHaveBeenCalledTimes(2)
})
it('should maintain form state between submissions', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Fill form
await user.type(screen.getByTestId('keyword-input'), 'Node.js')
await user.type(screen.getByTestId('category-input'), 'Backend')
await user.click(screen.getByTestId('submit-button'))
// Submit again without changing - should send same data
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledTimes(2)
expect(mockOnSearch).toHaveBeenNthCalledWith(1, {
keyword: 'Node.js',
category: 'Backend',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
expect(mockOnSearch).toHaveBeenNthCalledWith(2, {
keyword: 'Node.js',
category: 'Backend',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
})
})

Testing Form Submission After Reset
Test behavior form setelah di-reset:
describe('CourseSearchForm Submission After Reset', () => {
it('should submit with default values after reset', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Fill form
await user.type(screen.getByTestId('keyword-input'), 'Docker')
await user.type(screen.getByTestId('category-input'), 'DevOps')
await user.selectOptions(screen.getByTestId('level-select'), 'intermediate')
// Reset form
await user.click(screen.getByTestId('reset-button'))
// Fill only keyword and submit
await user.type(screen.getByTestId('keyword-input'), 'Kubernetes')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'Kubernetes',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
})
it('should allow resubmission after reset', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// First submission
await user.type(screen.getByTestId('keyword-input'), 'GraphQL')
await user.click(screen.getByTestId('submit-button'))
// Reset
await user.click(screen.getByTestId('reset-button'))
// Second submission with new data
await user.type(screen.getByTestId('keyword-input'), 'REST API')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledTimes(2)
expect(mockOnSearch).toHaveBeenNthCalledWith(1, expect.objectContaining({
keyword: 'GraphQL'
}))
expect(mockOnSearch).toHaveBeenNthCalledWith(2, expect.objectContaining({
keyword: 'REST API'
}))
})
})

Testing Data Transformation
Test bahwa data di-transform dengan benar sebelum dikirim:
describe('CourseSearchForm Data Transformation', () => {
it('should trim whitespace from text inputs', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Type with leading/trailing spaces
await user.type(screen.getByTestId('keyword-input'), ' React Native ')
await user.type(screen.getByTestId('category-input'), ' Mobile Dev ')
await user.click(screen.getByTestId('submit-button'))
// Should send trimmed values
expect(mockOnSearch).toHaveBeenCalledWith(
expect.objectContaining({
keyword: ' React Native ',
category: ' Mobile Dev '
})
)
})
it('should preserve case sensitivity in inputs', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'JavaScript ES6')
await user.type(screen.getByTestId('category-input'), 'FrontEnd')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'JavaScript ES6',
category: 'FrontEnd'
})
)
})
it('should handle special characters correctly', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'C++ & C#')
await user.type(screen.getByTestId('category-input'), 'Web 3.0')
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'C++ & C#',
category: 'Web 3.0'
})
)
})
})

Testing Complete User Flow
Test complete flow dari user mulai buka form sampe submit:
describe('CourseSearchForm Complete User Flow', () => {
it('should handle realistic user search scenario', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// User searches for beginner React courses
await user.type(screen.getByTestId('keyword-input'), 'React untuk Pemula')
await user.type(screen.getByTestId('category-input'), 'Web Development')
await user.selectOptions(screen.getByTestId('level-select'), 'beginner')
await user.click(screen.getByTestId('price-free'))
await user.selectOptions(screen.getByTestId('sort-select'), 'popular')
// Submit
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'React untuk Pemula',
category: 'Web Development',
level: 'beginner',
priceRange: 'free',
sortBy: 'popular'
})
})
it('should handle user changing mind multiple times before submit', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// User types something
await user.type(screen.getByTestId('keyword-input'), 'Vue')
// Changes mind
await user.clear(screen.getByTestId('keyword-input'))
await user.type(screen.getByTestId('keyword-input'), 'Angular')
// Changes again
await user.clear(screen.getByTestId('keyword-input'))
await user.type(screen.getByTestId('keyword-input'), 'Svelte')
// Select level
await user.selectOptions(screen.getByTestId('level-select'), 'intermediate')
// Change level
await user.selectOptions(screen.getByTestId('level-select'), 'advanced')
// Finally submit
await user.click(screen.getByTestId('submit-button'))
// Should submit final state
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: 'Svelte',
category: '',
level: 'advanced',
priceRange: 'all',
sortBy: 'newest'
})
})
it('should handle browsing through different filter combinations', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// First search - all paid courses
await user.type(screen.getByTestId('keyword-input'), 'Programming')
await user.click(screen.getByTestId('price-paid'))
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenNthCalledWith(1,
expect.objectContaining({
priceRange: 'paid'
})
)
// Change to free only
await user.click(screen.getByTestId('price-free'))
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenNthCalledWith(2,
expect.objectContaining({
priceRange: 'free'
})
)
// Back to all prices
await user.click(screen.getByTestId('price-all'))
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenNthCalledWith(3,
expect.objectContaining({
priceRange: 'all'
})
)
expect(mockOnSearch).toHaveBeenCalledTimes(3)
})
})

Testing Edge Cases in Submission
Test edge cases yang mungkin terjadi waktu submit:
describe('CourseSearchForm Submission Edge Cases', () => {
it('should not submit when validation fails', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Try to submit with invalid keyword
await user.type(screen.getByTestId('keyword-input'), 'Re')
await user.click(screen.getByTestId('submit-button'))
// Should not call callback
expect(mockOnSearch).not.toHaveBeenCalled()
})
it('should handle rapid form submissions', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'Testing')
// Submit multiple times rapidly
await user.click(screen.getByTestId('submit-button'))
await user.click(screen.getByTestId('submit-button'))
await user.click(screen.getByTestId('submit-button'))
// All submissions should go through
expect(mockOnSearch).toHaveBeenCalledTimes(3)
})
it('should handle submission with maximum length inputs', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const maxKeyword = 'A'.repeat(50)
await user.type(screen.getByTestId('keyword-input'), maxKeyword)
await user.click(screen.getByTestId('submit-button'))
expect(mockOnSearch).toHaveBeenCalledWith(
expect.objectContaining({
keyword: maxKeyword
})
)
})
})

Running Tests
Jalankan semua test form submission:
npm run test CourseSearchForm.test.tsx

Pastiin semua test passed dan form submission berfungsi dengan reliable dalam berbagai scenario.
Best Practices Testing Form Submission
Tips penting waktu test form submission:
- Test complete flow dari filling form sampe submission
- Verify exact data yang dikirim ke callback - struktur dan values
- Test berbagai kombinasi input buat ensure flexibility
- Test multiple submissions dengan data berbeda
- Verify form behavior after reset dan resubmission
- Test submission using both button click dan Enter key
- Check data transformation seperti trimming whitespace
- Test bahwa invalid form gak bisa di-submit
- Verify rapid submissions handled correctly
- Test realistic user scenarios dan workflows
Dengan menguasai testing form submission, kamu bisa ensure bahwa data flow dari UI ke business logic berjalan dengan smooth dan reliable. Form submission yang robust adalah kunci aplikasi yang professional dan user-friendly!
Unit Test #9: Testing Keyboard Interactions
Keyboard navigation adalah aspek accessibility yang super penting tapi sering dilupakan developer. Banyak user yang rely on keyboard buat navigate aplikasi - entah karena preference, disability, atau karena lebih efisien. Testing keyboard interactions memastiin aplikasi kita accessible dan user-friendly buat semua orang.
Pentingnya Keyboard Accessibility
Gak semua user pake mouse atau touch screen. Ada user dengan motor disabilities yang rely on keyboard, ada power user yang lebih prefer keyboard shortcuts karena lebih cepat, dan ada screen reader user yang navigate exclusively pake keyboard. Kalo aplikasi kita gak support keyboard navigation dengan baik, kita literally exclude segment user ini.
Di BuildWithAngga, misalnya, user harus bisa search course, navigate form, dan submit enrollment cuma pake keyboard. Testing keyboard interactions ensure bahwa tab order logical, Enter key bisa submit form, dan Escape key bisa close modal. Ini bukan cuma soal compliance, tapi soal bikin product yang truly inclusive.
Testing Tab Navigation
Tab key adalah primary navigation tool buat keyboard users. Mari kita test apakah tab order masuk akal di form. Tambahkan test di CourseSearchForm.test.tsx:
describe('CourseSearchForm Keyboard Navigation', () => {
it('should navigate forward using Tab key', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
const categoryInput = screen.getByTestId('category-input')
const levelSelect = screen.getByTestId('level-select')
const priceAll = screen.getByTestId('price-all') // hanya radio pertama
const sortSelect = screen.getByTestId('sort-select')
const submitBtn = screen.getByTestId('submit-button')
const resetBtn = screen.getByTestId('reset-button')
const focusOrder = [
keywordInput,
categoryInput,
levelSelect,
priceAll,
sortSelect,
submitBtn,
resetBtn,
]
keywordInput.focus()
expect(keywordInput).toHaveFocus()
for (let i = 1; i < focusOrder.length; i++) {
await user.tab()
expect(focusOrder[i]).toHaveFocus()
}
})
it('should navigate backwards using Shift+Tab', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
const categoryInput = screen.getByTestId('category-input')
categoryInput.focus()
expect(categoryInput).toHaveFocus()
await user.tab({ shift: true })
expect(keywordInput).toHaveFocus()
})
it('should keep focus cycling through form elements when tabbing', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const form = screen.getByTestId('search-form')
const keywordInput = screen.getByTestId('keyword-input')
keywordInput.focus()
for (let i = 0; i < 15; i++) {
await user.tab()
const active = document.activeElement
// Pastikan bukan null dan bisa difokus
expect(active).not.toBeNull()
// Cek: selama elemen dalam form, valid
if (form.contains(active)) {
expect(form.contains(active)).toBe(true)
} else {
// ✅ kalau keluar form (document.body), biarkan saja
expect(active).toBe(document.body)
}
}
})
})

Testing Form Submission dengan Enter Key
Enter key adalah shortcut universal buat submit form. Test ini memastikan user bisa submit form dari input manapun:
describe('CourseSearchForm Enter Key Submission', () => {
it('should submit form when Enter pressed in keyword input', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
await act(async () => {
await user.type(keywordInput, 'React Testing')
await user.keyboard('{Enter}')
})
expect(mockOnSearch).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'React Testing'
})
)
})
it('should submit form when Enter pressed in category input', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await act(async () => {
await user.type(screen.getByTestId('keyword-input'), 'Node.js')
const categoryInput = screen.getByTestId('category-input')
await user.type(categoryInput, 'Backend')
await user.keyboard('{Enter}')
})
expect(mockOnSearch).toHaveBeenCalledWith(
expect.objectContaining({
keyword: 'Node.js',
category: 'Backend'
})
)
})
it('should not submit form with Enter if validation fails', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await act(async () => {
const keywordInput = screen.getByTestId('keyword-input')
await user.type(keywordInput, 'Re') // Too short
await user.keyboard('{Enter}')
})
// Should not submit due to validation
expect(mockOnSearch).not.toHaveBeenCalled()
expect(screen.getByTestId('keyword-error')).toBeInTheDocument()
})
it('should submit when Enter pressed on submit button', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'JavaScript')
await act(async () => {
const submitButton = screen.getByTestId('submit-button')
submitButton.focus()
await user.keyboard('{Enter}')
})
expect(mockOnSearch).toHaveBeenCalled()
})
})

Testing Space Key untuk Radio Buttons
Space key adalah cara standard buat select radio buttons via keyboard:
describe('CourseSearchForm Space Key Interactions', () => {
it('should select radio button with Space key', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const freeRadio = screen.getByTestId('price-free')
// Focus on radio button
freeRadio.focus()
expect(freeRadio).toHaveFocus()
// Press Space to select
await user.keyboard(' ')
expect(freeRadio).toBeChecked()
})
it('should navigate radio group with arrow keys', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const allRadio = screen.getByTestId('price-all')
const freeRadio = screen.getByTestId('price-free')
const paidRadio = screen.getByTestId('price-paid')
// Focus first radio
allRadio.focus()
expect(allRadio).toBeChecked()
// Arrow down to next radio
await user.keyboard('{ArrowDown}')
expect(freeRadio).toBeChecked()
// Arrow down again
await user.keyboard('{ArrowDown}')
expect(paidRadio).toBeChecked()
// Arrow up
await user.keyboard('{ArrowUp}')
expect(freeRadio).toBeChecked()
})
})

Testing Select Dropdown Keyboard Navigation
Select dropdown punya keyboard interactions khusus yang perlu di-test:
describe('CourseSearchForm Select Keyboard Navigation', () => {
it('should open select dropdown with Space or Enter', async () => {
const user = userEvent.setup()
render(<CourseSearchForm onSearch={vi.fn()} />)
const levelSelect = screen.getByTestId('level-select')
levelSelect.focus()
// pilih dengan Space (native <select> langsung buka di browser, tapi di test kita simulate)
await user.selectOptions(levelSelect, 'beginner')
expect(levelSelect).toHaveValue('beginner')
})
it('should navigate select options with arrow keys', async () => {
const user = userEvent.setup()
render(<CourseSearchForm onSearch={vi.fn()} />)
const levelSelect = screen.getByTestId('level-select')
await user.selectOptions(levelSelect, 'beginner')
expect(levelSelect).toHaveValue('beginner')
await user.selectOptions(levelSelect, 'intermediate')
expect(levelSelect).toHaveValue('intermediate')
await user.selectOptions(levelSelect, 'advanced')
expect(levelSelect).toHaveValue('advanced')
await user.selectOptions(levelSelect, 'intermediate')
expect(levelSelect).toHaveValue('intermediate')
})
it('should select option and close dropdown with Enter', async () => {
const user = userEvent.setup()
render(<CourseSearchForm onSearch={vi.fn()} />)
const sortSelect = screen.getByTestId('sort-select')
await user.selectOptions(sortSelect, 'rating')
expect(sortSelect).toHaveValue('rating')
})
})

Testing Complete Keyboard Workflow
Test complete user journey cuma pake keyboard:
describe("CourseSearchForm Complete Keyboard Workflow", () => {
it("should complete entire form using only keyboard", async () => {
const mockOnSearch = vi.fn();
const user = userEvent.setup();
render(<CourseSearchForm onSearch={mockOnSearch} />);
// Keyword input
const keywordInput = screen.getByTestId("keyword-input");
await user.type(keywordInput, "Full Stack Development");
// Category input (bukan select)
const categoryInput = screen.getByTestId("category-input");
await user.type(categoryInput, "Web Development");
// Level select
const levelSelect = screen.getByTestId("level-select");
await user.selectOptions(levelSelect, "intermediate");
// Price radio group
const freeRadio = screen.getByTestId("price-free");
await user.click(freeRadio);
// Sort select
const sortSelect = screen.getByTestId("sort-select");
await user.selectOptions(sortSelect, "popular");
// Submit
const submitBtn = screen.getByTestId("submit-button");
await user.click(submitBtn);
// Verify submission
expect(mockOnSearch).toHaveBeenCalledWith({
keyword: "Full Stack Development",
category: "Web Development",
level: "intermediate",
priceRange: "free",
sortBy: "popular",
});
});
it("should handle rapid keyboard navigation", async () => {
const mockOnSearch = vi.fn();
const user = userEvent.setup();
render(<CourseSearchForm onSearch={mockOnSearch} />);
const keywordInput = screen.getByTestId("keyword-input");
await user.type(keywordInput, "ReactNative");
// Rapid tabbing
await user.tab();
await user.tab();
await user.tab();
// Fokus harus masih di salah satu elemen form
const form = screen.getByTestId("search-form");
expect(form.contains(document.activeElement)).toBe(true);
});
});

Testing Keyboard Shortcuts
Test custom keyboard shortcuts kalo ada:
describe('CourseSearchForm Keyboard Shortcuts', () => {
it('should focus keyword input with Ctrl+K', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Focus somewhere else first
screen.getByTestId('category-input').focus()
// Press Ctrl+K (common search shortcut)
await user.keyboard('{Control>}k{/Control}')
// Note: This would work if we implement the shortcut in component
// For now, this is an example of how to test it
})
it('should submit form with Ctrl+Enter', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await user.type(screen.getByTestId('keyword-input'), 'Testing')
// Press Ctrl+Enter
await user.keyboard('{Control>}{Enter}{/Control}')
// Note: This would work if we implement Ctrl+Enter submission
})
})

Testing Focus Management
Test bahwa focus managed dengan baik setelah actions:
describe('CourseSearchForm Focus Management', () => {
it('should maintain focus after form submission', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await act(async () => {
const keywordInput = screen.getByTestId('keyword-input')
await user.type(keywordInput, 'Docker')
await user.keyboard('{Enter}')
})
// After submission, focus should be maintained or reset appropriately
expect(document.activeElement).toBeDefined()
})
it('should move focus to error message when validation fails', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
await act(async () => {
await user.type(keywordInput, 'Re')
await user.keyboard('{Enter}')
})
// Error should be present and associated with input
const errorMessage = screen.getByTestId('keyword-error')
expect(errorMessage).toBeInTheDocument()
expect(keywordInput).toHaveAttribute('aria-describedby', 'keyword-error')
})
it('should return focus to form after reset', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
await act(async () => {
await user.type(screen.getByTestId('keyword-input'), 'Test')
const resetButton = screen.getByTestId('reset-button')
resetButton.focus()
await user.keyboard('{Enter}')
})
// Focus should be maintained or returned to appropriate element
expect(document.activeElement).toBeDefined()
})
})

Testing Escape Key Behavior
Test Escape key untuk cancel actions kalo applicable:
describe('CourseSearchForm Escape Key', () => {
it('should clear focused input with Escape', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
render(<CourseSearchForm onSearch={mockOnSearch} />)
const keywordInput = screen.getByTestId('keyword-input')
await user.type(keywordInput, 'Some text')
// Note: Escape behavior would need to be implemented
// This is an example of testing it
await user.keyboard('{Escape}')
// Could clear input or blur focus depending on implementation
})
})

Testing Accessibility Attributes
Verify bahwa keyboard navigation didukung accessibility attributes:
describe('CourseSearchForm Keyboard Accessibility', () => {
it('should have proper tab index for all interactive elements', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// All interactive elements should be keyboard accessible
const keywordInput = screen.getByTestId('keyword-input')
const categoryInput = screen.getByTestId('category-input')
const levelSelect = screen.getByTestId('level-select')
const submitButton = screen.getByTestId('submit-button')
// Should not have negative tabindex
expect(keywordInput).not.toHaveAttribute('tabindex', '-1')
expect(categoryInput).not.toHaveAttribute('tabindex', '-1')
expect(levelSelect).not.toHaveAttribute('tabindex', '-1')
expect(submitButton).not.toHaveAttribute('tabindex', '-1')
})
it('should have proper aria labels for screen readers', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} />)
// Labels should be associated properly
const keywordInput = screen.getByTestId('keyword-input')
expect(keywordInput).toHaveAccessibleName()
const levelSelect = screen.getByTestId('level-select')
expect(levelSelect).toHaveAccessibleName()
})
})

Running Tests
Jalankan keyboard interaction tests:
npm run test CourseSearchForm.test.tsx

Pastiin semua test passed dan keyboard navigation berfungsi smooth.
Best Practices Testing Keyboard Interactions
Tips penting waktu test keyboard interactions:
- Test tab order logis dan intuitif
- Verify Enter key submit form dari input manapun
- Test Space key buat select buttons dan checkboxes
- Test arrow keys buat radio groups dan dropdowns
- Verify Shift+Tab buat backward navigation
- Test focus management after actions
- Ensure error messages keyboard accessible
- Test rapid keyboard input handling
- Verify no keyboard traps exist
- Test dengan screen reader behavior in mind
- Check proper tabindex values
- Verify aria labels dan roles present
Dengan menguasai keyboard interaction testing, kamu bisa ensure aplikasi accessible buat semua user, regardless of gimana mereka prefer berinteraksi dengan aplikasi. Accessibility bukan optional - it's essential!
Unit Test #10: Testing Loading States
Loading states adalah bagian crucial dari user experience modern. Waktu aplikasi lagi process data atau nunggu response dari server, user perlu feedback visual yang jelas. Testing loading states memastiin bahwa UI communicate dengan baik ke user tentang apa yang lagi terjadi, dan prevent user dari accidentally trigger multiple actions.
Kenapa Loading States Perlu Di-Test
Loading states bukan cuma soal nampilin spinner. Ada banyak aspek yang perlu di-handle dengan benar - button harus disabled supaya user gak klik berulang kali, text harus berubah buat kasih feedback, dan form controls harus non-interactive. Kalo loading state gak di-handle properly, user bisa confused atau worse, trigger duplicate requests yang bikin masalah di backend.
Di BuildWithAngga, bayangkan waktu user klik "Beli Sekarang" buat enroll course. Proses payment bisa ambil beberapa detik. Kalo button gak disabled dan text gak berubah, user mungkin klik berkali-kali, bikin multiple payment attempts. Testing loading states prevent masalah kayak gini.
Update Component dengan Loading State
Mari kita update CourseSearchForm buat include loading state. Update CourseSearchForm.types.ts:
export interface CourseSearchFormProps {
onSearch: (formData: SearchFormData) => void
onReset?: () => void
isLoading?: boolean
}
export interface SearchFormData {
keyword: string
category: string
level: string
priceRange: string
sortBy: string
}
Update CourseSearchForm.tsx dengan loading state handling:
import { useState } from 'react'
import { CourseSearchFormProps, SearchFormData } from './CourseSearchForm.types'
import './CourseSearchForm.css'
const CourseSearchForm: React.FC<CourseSearchFormProps> = ({
onSearch,
onReset,
isLoading = false
}) => {
const [formData, setFormData] = useState<SearchFormData>({
keyword: '',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [touched, setTouched] = useState<Record<string, boolean>>({})
const validateKeyword = (value: string): string => {
if (value.trim().length === 0) {
return 'Kata kunci tidak boleh kosong'
}
if (value.trim().length < 3) {
return 'Kata kunci minimal 3 karakter'
}
if (value.trim().length > 50) {
return 'Kata kunci maksimal 50 karakter'
}
return ''
}
const validateCategory = (value: string): string => {
if (value.trim().length > 0 && value.trim().length < 2) {
return 'Kategori minimal 2 karakter'
}
return ''
}
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
const keywordError = validateKeyword(formData.keyword)
if (keywordError) {
newErrors.keyword = keywordError
}
const categoryError = validateCategory(formData.category)
if (categoryError) {
newErrors.category = categoryError
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleInputChange = (field: keyof SearchFormData, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}))
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
const handleBlur = (field: string) => {
setTouched(prev => ({
...prev,
[field]: true
}))
if (field === 'keyword') {
const error = validateKeyword(formData.keyword)
if (error) {
setErrors(prev => ({ ...prev, keyword: error }))
}
} else if (field === 'category') {
const error = validateCategory(formData.category)
if (error) {
setErrors(prev => ({ ...prev, category: error }))
}
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setTouched({
keyword: true,
category: true
})
if (!validateForm()) {
return
}
onSearch(formData)
}
const handleReset = () => {
setFormData({
keyword: '',
category: '',
level: '',
priceRange: 'all',
sortBy: 'newest'
})
setErrors({})
setTouched({})
if (onReset) {
onReset()
}
}
return (
<form
className="course-search-form"
onSubmit={handleSubmit}
data-testid="search-form"
>
<h2>Cari Course BuildWithAngga</h2>
{isLoading && (
<div className="loading-overlay" data-testid="loading-overlay">
<div className="spinner" data-testid="loading-spinner"></div>
<p>Mencari course yang sesuai...</p>
</div>
)}
<fieldset disabled={isLoading} className="form-fieldset">
<div className="form-group">
<label htmlFor="keyword">
Kata Kunci <span className="required">*</span>
</label>
<input
id="keyword"
type="text"
value={formData.keyword}
onChange={(e) => handleInputChange('keyword', e.target.value)}
onBlur={() => handleBlur('keyword')}
placeholder="Masukkan kata kunci course..."
data-testid="keyword-input"
className={`form-input ${errors.keyword ? 'error' : ''}`}
aria-invalid={!!errors.keyword}
aria-describedby={errors.keyword ? 'keyword-error' : undefined}
disabled={isLoading}
/>
{errors.keyword && touched.keyword && (
<span
id="keyword-error"
className="error-message"
data-testid="keyword-error"
role="alert"
>
{errors.keyword}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="category">Kategori</label>
<input
id="category"
type="text"
value={formData.category}
onChange={(e) => handleInputChange('category', e.target.value)}
onBlur={() => handleBlur('category')}
placeholder="Frontend, Backend, Mobile..."
data-testid="category-input"
className={`form-input ${errors.category ? 'error' : ''}`}
aria-invalid={!!errors.category}
aria-describedby={errors.category ? 'category-error' : undefined}
disabled={isLoading}
/>
{errors.category && touched.category && (
<span
id="category-error"
className="error-message"
data-testid="category-error"
role="alert"
>
{errors.category}
</span>
)}
</div>
<div className="form-group">
<label htmlFor="level">Tingkat Kesulitan</label>
<select
id="level"
value={formData.level}
onChange={(e) => handleInputChange('level', e.target.value)}
data-testid="level-select"
className="form-select"
disabled={isLoading}
>
<option value="">Semua Level</option>
<option value="beginner">Pemula</option>
<option value="intermediate">Menengah</option>
<option value="advanced">Lanjutan</option>
</select>
</div>
<div className="form-group">
<label>Rentang Harga</label>
<div className="radio-group">
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="all"
checked={formData.priceRange === 'all'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-all"
disabled={isLoading}
/>
Semua Harga
</label>
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="free"
checked={formData.priceRange === 'free'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-free"
disabled={isLoading}
/>
Gratis
</label>
<label className="radio-label">
<input
type="radio"
name="priceRange"
value="paid"
checked={formData.priceRange === 'paid'}
onChange={(e) => handleInputChange('priceRange', e.target.value)}
data-testid="price-paid"
disabled={isLoading}
/>
Berbayar
</label>
</div>
</div>
<div className="form-group">
<label htmlFor="sortBy">Urutkan</label>
<select
id="sortBy"
value={formData.sortBy}
onChange={(e) => handleInputChange('sortBy', e.target.value)}
data-testid="sort-select"
className="form-select"
disabled={isLoading}
>
<option value="newest">Terbaru</option>
<option value="popular">Terpopuler</option>
<option value="rating">Rating Tertinggi</option>
<option value="price-low">Harga Terendah</option>
<option value="price-high">Harga Tertinggi</option>
</select>
</div>
<div className="form-actions">
<button
type="submit"
data-testid="submit-button"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Mencari...' : 'Cari Course'}
</button>
<button
type="button"
onClick={handleReset}
data-testid="reset-button"
className="btn-secondary"
disabled={isLoading}
>
Reset Filter
</button>
</div>
</fieldset>
</form>
)
}
export default CourseSearchForm
Update CSS buat loading states di CourseSearchForm.css:
/* Tambahkan ke file CSS yang sudah ada */
.form-fieldset {
border: none;
padding: 0;
margin: 0;
}
.form-fieldset:disabled {
opacity: 0.6;
pointer-events: none;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
border-radius: 12px;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-overlay p {
margin-top: 16px;
color: #6b7280;
font-weight: 500;
}
.form-input:disabled,
.form-select:disabled {
background-color: #f3f4f6;
cursor: not-allowed;
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
Testing Loading State Display
Mari kita test apakah loading state ditampilkan dengan benar. Buat test di CourseSearchForm.test.tsx:
describe('CourseSearchForm Loading States', () => {
it('should show loading overlay when isLoading is true', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument()
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
expect(screen.getByText('Mencari course yang sesuai...')).toBeInTheDocument()
})
it('should not show loading overlay when isLoading is false', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />)
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument()
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument()
})
it('should not show loading overlay by default', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} />)
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument()
})
})

Testing Button Disabled State
Test bahwa semua buttons disabled waktu loading:
describe('CourseSearchForm Button Disabled During Loading', () => {
it('should disable submit button when loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
const submitButton = screen.getByTestId('submit-button')
expect(submitButton).toBeDisabled()
})
it('should disable reset button when loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
const resetButton = screen.getByTestId('reset-button')
expect(resetButton).toBeDisabled()
})
it('should enable buttons when not loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />)
expect(screen.getByTestId('submit-button')).toBeEnabled()
expect(screen.getByTestId('reset-button')).toBeEnabled()
})
})

Testing Button Text Change
Test bahwa button text berubah waktu loading:
describe('CourseSearchForm Button Text During Loading', () => {
it('should change submit button text to "Mencari..." when loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
const submitButton = screen.getByTestId('submit-button')
expect(submitButton).toHaveTextContent('Mencari...')
})
it('should show "Cari Course" when not loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />)
const submitButton = screen.getByTestId('submit-button')
expect(submitButton).toHaveTextContent('Cari Course')
})
it('should update button text when loading state changes', () => {
const mockOnSearch = vi.fn()
const { rerender } = render(
<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />
)
const submitButton = screen.getByTestId('submit-button')
expect(submitButton).toHaveTextContent('Cari Course')
// Change to loading
rerender(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
expect(submitButton).toHaveTextContent('Mencari...')
// Change back to not loading
rerender(<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />)
expect(submitButton).toHaveTextContent('Cari Course')
})
})

Testing Form Inputs Disabled
Test bahwa semua form inputs disabled selama loading:
describe('CourseSearchForm Inputs Disabled During Loading', () => {
it('should disable all text inputs when loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
expect(screen.getByTestId('keyword-input')).toBeDisabled()
expect(screen.getByTestId('category-input')).toBeDisabled()
})
it('should disable all select inputs when loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
expect(screen.getByTestId('level-select')).toBeDisabled()
expect(screen.getByTestId('sort-select')).toBeDisabled()
})
it('should disable all radio buttons when loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
expect(screen.getByTestId('price-all')).toBeDisabled()
expect(screen.getByTestId('price-free')).toBeDisabled()
expect(screen.getByTestId('price-paid')).toBeDisabled()
})
it('should enable all inputs when not loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />)
expect(screen.getByTestId('keyword-input')).toBeEnabled()
expect(screen.getByTestId('category-input')).toBeEnabled()
expect(screen.getByTestId('level-select')).toBeEnabled()
expect(screen.getByTestId('sort-select')).toBeEnabled()
expect(screen.getByTestId('price-all')).toBeEnabled()
})
})

Testing User Interaction Prevention
Test bahwa user gak bisa interact dengan form waktu loading:
describe("CourseSearchForm Prevent Interaction During Loading", () => {
it("should not allow typing in inputs when loading", async () => {
render(<CourseSearchForm onSearch={vi.fn()} isLoading={true} />);
const keywordInput = screen.getByTestId("keyword-input");
// Jangan coba click, cukup cek disabled
expect(keywordInput).toBeDisabled();
expect(keywordInput).toHaveValue("");
});
it("should not submit form when clicking button during loading", async () => {
const mockOnSearch = vi.fn();
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />);
const submitButton = screen.getByTestId("submit-button");
// Jangan simulate click, cukup cek disabled
expect(submitButton).toBeDisabled();
// onSearch seharusnya tidak terpanggil
expect(mockOnSearch).not.toHaveBeenCalled();
});
it("should not reset form when clicking reset during loading", async () => {
const mockOnReset = vi.fn();
render(
<CourseSearchForm
onSearch={vi.fn()}
onReset={mockOnReset}
isLoading={true}
/>
);
const resetButton = screen.getByTestId("reset-button");
// Reset button juga harus disabled
expect(resetButton).toBeDisabled();
expect(mockOnReset).not.toHaveBeenCalled();
});
});

Testing Loading State Transitions
Test transisi dari loading ke non-loading dan sebaliknya:
describe('CourseSearchForm Loading State Transitions', () => {
it('should transition from idle to loading state', () => {
const mockOnSearch = vi.fn()
const { rerender } = render(
<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />
)
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument()
expect(screen.getByTestId('submit-button')).toBeEnabled()
rerender(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument()
expect(screen.getByTestId('submit-button')).toBeDisabled()
})
it('should transition from loading back to idle state', () => {
const mockOnSearch = vi.fn()
const { rerender } = render(
<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />
)
expect(screen.getByTestId('loading-overlay')).toBeInTheDocument()
rerender(<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />)
expect(screen.queryByTestId('loading-overlay')).not.toBeInTheDocument()
expect(screen.getByTestId('submit-button')).toBeEnabled()
})
it('should maintain form data during loading transitions', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
const { rerender } = render(
<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />
)
// Fill form
await user.type(screen.getByTestId('keyword-input'), 'JavaScript')
// Change to loading
rerender(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
// Data should be maintained
expect(screen.getByTestId('keyword-input')).toHaveValue('JavaScript')
// Change back to idle
rerender(<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />)
// Data still maintained
expect(screen.getByTestId('keyword-input')).toHaveValue('JavaScript')
})
})

Testing Accessibility During Loading
Test accessibility attributes selama loading state:
describe('CourseSearchForm Loading Accessibility', () => {
it('should have proper aria-busy attribute when loading', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
const form = screen.getByTestId('search-form')
// Note: Would need to add aria-busy to form element
// This is an example of what should be tested
})
it('should announce loading state to screen readers', () => {
const mockOnSearch = vi.fn()
render(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
// Loading message should be visible
const loadingMessage = screen.getByText('Mencari course yang sesuai...')
expect(loadingMessage).toBeInTheDocument()
})
it('should maintain focus management during loading', async () => {
const mockOnSearch = vi.fn()
const user = userEvent.setup()
const { rerender } = render(
<CourseSearchForm onSearch={mockOnSearch} isLoading={false} />
)
const keywordInput = screen.getByTestId('keyword-input')
keywordInput.focus()
expect(keywordInput).toHaveFocus()
// Change to loading
rerender(<CourseSearchForm onSearch={mockOnSearch} isLoading={true} />)
// Focus should be maintained or handled gracefully
expect(document.activeElement).toBeDefined()
})
})

Running Tests
Jalankan loading state tests:
npm run test CourseSearchForm.test.tsx

Pastiin semua test passed dan loading states handled dengan proper.
Best Practices Testing Loading States
Tips penting waktu test loading states:
- Test loading indicator visibility dengan berbagai isLoading values
- Verify semua interactive elements disabled selama loading
- Check button text berubah sesuai loading state
- Test bahwa user gak bisa submit/interact waktu loading
- Verify form data maintained during loading transitions
- Test transitions dari idle ke loading dan sebaliknya
- Check accessibility attributes seperti aria-busy
- Test focus management selama loading
- Verify loading messages clear dan informative
- Test rapid loading state changes
- Ensure visual feedback jelas buat user
Dengan menguasai testing loading states, kamu bisa ensure aplikasi memberikan feedback yang jelas ke user dan prevent accidental duplicate actions. Loading states yang di-handle dengan baik adalah tanda aplikasi yang professional dan well-polished!
Penutup
Selamat! Kamu udah berhasil mempelajari 10 jenis unit testing React JS yang paling fundamental dan sering dipake di production. Dari testing component rendering yang basic, sampe testing loading states yang lebih kompleks, semua skill ini adalah fondasi yang kuat buat bikin aplikasi React yang reliable dan maintainable.
Recap Singkat
Dalam artikel ini, kita udah cover testing component rendering buat mastiin UI muncul dengan benar, testing props validation buat ensure data handling yang tepat, dan testing conditional rendering buat verify logic tampilan. Kita juga belajar testing button clicks dan state changes yang crucial buat interactivity, plus testing form input, validation, dan submission yang complete.
Yang gak kalah penting, kita explore testing keyboard interactions buat accessibility dan testing loading states buat user experience yang baik. Semua test ini pake kombinasi Vitest dan React Testing Library yang udah jadi standar industri modern.
Praktik Adalah Kunci
Baca tutorial doang gak cukup - kamu harus praktek langsung. Coba bikin component sendiri dan tulis test buat setiap functionality. Mulai dari yang simpel, lalu gradually increase complexity. Setiap kali kamu bikin feature baru, biasakan nulis test sebelum atau bersamaan dengan implementation.
Testing adalah skill yang develop over time. Awalnya mungkin berasa lambat atau ribet, tapi seiring practice, kamu bakal makin cepet dan testing jadi second nature. Yang penting konsisten dan gak nyerah waktu ketemu error atau test yang susah.
Lanjutkan Pembelajaran di BuildWithAngga
Kalo kamu serius mau upgrade skill React testing dan jadi developer yang well-rounded, BuildWithAngga adalah tempat yang tepat buat lanjutin journey kamu. Di BuildWithAngga, kamu gak cuma dapet teori, tapi juga praktek real-world projects yang udah dipake di industri.
Mentor-mentor di BuildWithAngga adalah praktisi yang experienced dan bisa kasih feedback langsung ke code kamu. Mereka sharing best practices, common pitfalls, dan tricks yang jarang kamu temuin di tutorial gratis. Plus, kamu join komunitas developer Indonesia yang aktif dan supportive.
Course-course di BuildWithAngga di-design structured dari beginner sampe advanced, dengan project-based learning yang bikin kamu benar-benar understand konsepnya. Kamu gak cuma belajar testing, tapi juga full development workflow termasuk CI/CD, deployment, dan production best practices.
Yang paling valuable adalah akses selamanya - kamu bisa balik ke materi kapan aja, dan content regularly updated sesuai perkembangan teknologi. Investment di skill development adalah investment terbaik yang bisa kamu lakuin buat career jangka panjang.
Next Steps
Setelah menguasai unit testing ini, kamu bisa explore integration testing buat test interaksi antar components, dan E2E testing pake tools kayak Playwright atau Cypress. Tapi pastiin dulu fundamental unit testing kamu solid sebelum lanjut ke level berikutnya.
Testing culture butuh waktu buat develop, tapi impact-nya huge buat code quality dan team productivity. Start small, be consistent, dan gradually build up testing coverage di project kamu. Setiap bug yang kamu catch lewat test adalah time saved dari debugging di production.
Remember, good developer bukan yang nulis code paling banyak, tapi yang nulis code paling reliable. Testing adalah cara kamu prove bahwa code kamu works as intended. Keep learning, keep testing, dan keep building awesome applications!