10 Unit Testing React JS yang Wajib Dikuasai: Tutorial Vitest dan Testing Library

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:

  1. Testing component rendering
  2. Testing props validation
  3. Testing conditional rendering
  4. Testing button click events
  5. Testing state changes
  6. Testing form input
  7. Testing form validation
  8. Testing form submission
  9. Testing keyboard interactions
  10. 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:

  1. Project name: ketik bwa-testing-tutorial
  2. Select a framework: pilih React
  3. 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

Tampilan Default Vite
Tampilan Default Vite

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 perubahan
  • test:ui - Buka Vitest UI interface di browser, berguna buat debugging visual
  • test:run - Run tests sekali aja tanpa watch, biasanya dipake di CI/CD
  • test: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: .tsx dan .test.tsx
  • Utility functions: .ts dan .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

npm run test
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:

Test Rendering Component
Test Rendering Component

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:

  1. getByRole - Paling direkomendasiin karena accessible
  2. getByLabelText - Bagus buat form elements
  3. getByPlaceholderText - Alternatif buat inputs
  4. getByText - Bagus buat content
  5. 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')
  })
})

Test Validation Props
Test Validation Props

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.

Test Optional Props
Test Optional Props

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')
  })
})

Testing Edge Case Props
Testing Edge Case Props

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')
  })
})

Test Data Display
Test Data Display

Running Tests

Jalankan test buat mastiin semua props handling berjalan dengan baik:

npm run test

Test Validasi Props
Test Validasi Props

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 queryBy buat 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.

Test Rendering Component yang Harusnya Tidak Muncul
Test Rendering Component yang Harusnya Tidak Muncul

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')
  })
})

Test Conditional Rendering
Test Conditional Rendering

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')
  })
})

Test Discount Logic
Test Discount Logic

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()
  })
})

Test Condition Edge
Test Condition Edge

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()
  })
})

Test Rerender
Test Rerender

Running Tests dan Debugging

Jalankan semua test conditional rendering:

npm run test CourseCard.test.tsx

Test Conditional Rendering
Test Conditional Rendering

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 queryBy instead of getBy buat 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 queryBy buat element yang conditional, getBy buat 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)
  })
})

Test Basic Click
Test Basic Click

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)
  })
})

Test Multiple Click
Test Multiple Click

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)
  })
})

Test Button State Change
Test Button State Change

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)
  })
})

Test Button Text Conditional
Test Button Text Conditional

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()
  })
})

Test Button Tanpa Callback
Test Button Tanpa Callback

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('♥')
  })
})

Test Multiple Button
Test Multiple Button

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
  })
})

Test Mock Function
Test Mock Function

Running Tests

Jalankan test button clicks:

npm run test CourseCard.test.tsx

Test Semua Button Click
Test Semua Button Click

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 userEvent instead of fireEvent buat interaksi yang lebih realistic
  • Jangan lupa await waktu 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()
  })
})

Test Toggle State
Test Toggle State

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()
  })
})

Test Step Navigation State
Test Step Navigation State

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()
  })
})

Test Radio Button State
Test Radio Button State

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()
  })
})

Test Processing State
Test Processing State

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()
  })
})

Test Complex State Interactions
Test Complex State Interactions

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

Test State
Test State

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 findBy untuk 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('')
  })
})

Test Input Text
Test Input Text

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)
    }
  })
})

Test Select Dropdown
Test Select Dropdown

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()
  })
})

Test Radio Button
Test Radio Button

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'
    })
  })
})

Test Complate Form
Test Complate Form

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')
  })
})

Test Reset Form
Test Reset Form

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'
    })
  })
})

Test Special Character
Test Special Character

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()
  })
})

Test Rapid Input Changes
Test Rapid Input Changes

Running Tests

Jalankan test form input:

npm run test CourseSearchForm.test.tsx

image.png
Test Input

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()
  })
})

Test Validation
Test Validation

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()
    })
  })
})

Test Error Clearing
Test Error Clearing

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()
  })
})

Test Validation on Blur
Test Validation on Blur

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)
  })
})

Test Successful Form Sumission
Test Successful Form Sumission

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'
    })
  })
})

Tetst Multiple Validation Error
Tetst Multiple Validation Error

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')
  })
})

Test Accessiblity Attributes
Test Accessiblity Attributes

Running Tests

Jalankan test validation:

npm run test CourseSearchForm.test.tsx

Test Validation
Test Validation

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'
    })
  })
})

Test Submission
Test Submission

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'
    })
  })
})

Test Different Input Combinations
Test Different Input Combinations

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'
    })
  })
})

Test Multiple Submissions
Test Multiple Submissions

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'
    }))
  })
})

Test Form Submission After Reset
Test Form Submission After Reset

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'
      })
    )
  })
})

Test Data Transformation
Test Data Transformation

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)
  })
})

Test Complate User Flow
Test Complate User Flow

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
      })
    )
  })
})

Test Edge Submission
Test Edge Submission

Running Tests

Jalankan semua test form submission:

npm run test CourseSearchForm.test.tsx

Test Semua Submission
Test Semua Submission

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)
    }
  }
})

})
Test Tab Navigation
Test Tab Navigation

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()
  })
})
Test Enter Submission
Test Enter Submission

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()
  })
})

Test Space Key Radio Button
Test Space Key Radio Button

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')
  })
})
Test Select Dropdown Keyboard Navigation
Test Select Dropdown Keyboard Navigation

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);
  });
});
Test Complatet Keyboard Workflow
Test Complatet Keyboard Workflow

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
  })
})

Test Keyboard Shortcuts
Test Keyboard Shortcuts

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()
  })
})
Test Focus Management
Test Focus Management

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
  })
})

Test Escape Key Behavior
Test Escape Key Behavior

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()
  })
})

Test Accessiblity Attrirbutes
Test Accessiblity Attrirbutes

Running Tests

Jalankan keyboard interaction tests:

npm run test CourseSearchForm.test.tsx

Test Keyboard Interactions
Test Keyboard Interactions

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()
  })
})

Test Loading State Display
Test Loading State Display

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()
  })
})

Test Button Disale State
Test Button Disale State

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')
  })
})

Test Button Text Change
Test Button Text Change

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()
  })
})

Test Form Input Disabled
Test Form Input Disabled

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();
  });
});

Test User Interaction
Test User Interaction

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')
  })
})

Test Loading State Transition
Test Loading State Transition

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()
  })
})

Tetst Accesibility During Loading
Tetst Accesibility During Loading

Running Tests

Jalankan loading state tests:

npm run test CourseSearchForm.test.tsx

Test Semua Loading
Test Semua Loading

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!