Testing Your Document Q&A Application

Overview

Comprehensive testing is essential for ensuring the reliability and quality of your Document Q&A application. This tutorial covers various testing strategies for both frontend and backend components, helping you build a robust application that handles edge cases gracefully.

Frontend Testing

1. Setting Up Jest with React Testing Library

First, set up Jest and React Testing Library in your Next.js project:

# Install dependencies
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom

# Create jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
  moduleNameMapper: {
    '^@/components/(.*)$': '<rootDir>/src/components/$1',
    '^@/lib/(.*)$': '<rootDir>/src/lib/$1',
  },
};

# Create jest.setup.js
import '@testing-library/jest-dom';

# Add to package.json
"scripts": {
  "test": "jest",
  "test:watch": "jest --watch"
}

2. Component Testing

Test your React components to ensure they render correctly and handle user interactions:

// src/components/QuestionInput.test.js
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import QuestionInput from './QuestionInput';
import { client } from '../lib/document-qa';

// Mock the document-qa client
jest.mock('../lib/document-qa', () => ({
  client: {
    askQuestion: jest.fn(),
  },
}));

describe('QuestionInput Component', () => {
  beforeEach(() => {
    // Reset mocks before each test
    jest.clearAllMocks();
  });

  test('renders the question input form', () => {
    render(<QuestionInput documentId="test-doc-123" />);
    
    // Check if the input field and button are rendered
    expect(screen.getByPlaceholderText(/ask a question/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /ask question/i })).toBeInTheDocument();
  });

  test('handles question submission', async () => {
    // Mock the API response
    client.askQuestion.mockResolvedValueOnce('This is the answer to your question.');
    
    render(<QuestionInput documentId="test-doc-123" />);
    
    // Type a question
    const inputElement = screen.getByPlaceholderText(/ask a question/i);
    await userEvent.type(inputElement, 'What is the main topic?');
    
    // Submit the form
    const buttonElement = screen.getByRole('button', { name: /ask question/i });
    fireEvent.click(buttonElement);
    
    // Check if the API was called with correct parameters
    expect(client.askQuestion).toHaveBeenCalledWith({
      documentId: 'test-doc-123',
      question: 'What is the main topic?',
    });
    
    // Wait for the answer to be displayed
    await waitFor(() => {
      expect(screen.getByText('This is the answer to your question.')).toBeInTheDocument();
    });
  });

  test('displays error message when API call fails', async () => {
    // Mock the API error
    client.askQuestion.mockRejectedValueOnce({
      response: { data: { detail: 'Failed to process question' } },
    });
    
    render(<QuestionInput documentId="test-doc-123" />);
    
    // Type a question and submit
    const inputElement = screen.getByPlaceholderText(/ask a question/i);
    await userEvent.type(inputElement, 'What is the main topic?');
    fireEvent.click(screen.getByRole('button', { name: /ask question/i }));
    
    // Check if error message is displayed
    await waitFor(() => {
      expect(screen.getByText('Failed to process question')).toBeInTheDocument();
    });
  });
});

3. Testing File Upload Components

Test file upload components with mocked file objects:

// src/components/FileUpload.test.js
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import FileUpload from './FileUpload';
import apiClient from '../lib/api-client';

// Mock the API client
jest.mock('../lib/api-client', () => ({
  post: jest.fn(),
}));

// Mock react-dropzone
jest.mock('react-dropzone', () => ({
  useDropzone: () => ({
    getRootProps: () => ({
      onClick: jest.fn(),
    }),
    getInputProps: () => ({}),
    isDragActive: false,
    acceptedFiles: [],
  }),
}));

describe('FileUpload Component', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('renders the dropzone', () => {
    render(<FileUpload onDocumentUploaded={() => {}} />);
    
    expect(screen.getByText(/drag and drop your file here/i)).toBeInTheDocument();
  });

  test('handles file upload', async () => {
    // Mock successful API response
    apiClient.post.mockResolvedValueOnce({
      data: { document_id: 'test-doc-123' },
    });
    
    const onDocumentUploaded = jest.fn();
    render(<FileUpload onDocumentUploaded={onDocumentUploaded} />);
    
    // Create a mock file
    const file = new File(['file content'], 'test.pdf', { type: 'application/pdf' });
    
    // Simulate file drop
    const dropzone = screen.getByText(/drag and drop your file here/i).closest('div');
    fireEvent.drop(dropzone, {
      dataTransfer: {
        files: [file],
      },
    });
    
    // Wait for the upload to complete
    await waitFor(() => {
      expect(apiClient.post).toHaveBeenCalledWith('/upload', expect.any(FormData), expect.any(Object));
      expect(onDocumentUploaded).toHaveBeenCalledWith('test-doc-123');
    });
  });

  test('displays error message when upload fails', async () => {
    // Mock API error
    apiClient.post.mockRejectedValueOnce({
      response: { data: { detail: 'Invalid file format' } },
    });
    
    render(<FileUpload onDocumentUploaded={() => {}} />);
    
    // Create a mock file
    const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
    
    // Simulate file drop
    const dropzone = screen.getByText(/drag and drop your file here/i).closest('div');
    fireEvent.drop(dropzone, {
      dataTransfer: {
        files: [file],
      },
    });
    
    // Check if error message is displayed
    await waitFor(() => {
      expect(screen.getByText('Invalid file format')).toBeInTheDocument();
    });
  });
});

Integration Testing

1. Testing API Integration

Test the integration between your frontend and API:

// src/tests/integration/document-qa.test.js
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../../app/page';
import apiClient from '../../lib/api-client';

// Mock the API client
jest.mock('../../lib/api-client', () => ({
  post: jest.fn(),
}));

describe('Document Q&A Integration', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('complete document upload and question flow', async () => {
    // Mock successful upload
    apiClient.post.mockResolvedValueOnce({
      data: { document_id: 'test-doc-123' },
    });
    
    // Mock successful question response
    apiClient.post.mockResolvedValueOnce({
      data: { answer: 'This is the answer to your question.' },
    });
    
    render(<App />);
    
    // Step 1: Upload a document
    const file = new File(['file content'], 'test.pdf', { type: 'application/pdf' });
    const dropzone = screen.getByText(/drag and drop your file here/i).closest('div');
    
    fireEvent.drop(dropzone, {
      dataTransfer: {
        files: [file],
      },
    });
    
    // Wait for upload to complete and UI to update
    await waitFor(() => {
      expect(screen.getByText(/ask questions/i)).toBeInTheDocument();
    });
    
    // Step 2: Ask a question
    const inputElement = screen.getByPlaceholderText(/ask a question/i);
    await userEvent.type(inputElement, 'What is the main topic?');
    
    fireEvent.click(screen.getByRole('button', { name: /ask question/i }));
    
    // Check if the answer is displayed
    await waitFor(() => {
      expect(screen.getByText('This is the answer to your question.')).toBeInTheDocument();
    });
    
    // Verify API calls
    expect(apiClient.post).toHaveBeenCalledTimes(2);
    expect(apiClient.post.mock.calls[0][0]).toBe('/upload');
    expect(apiClient.post.mock.calls[1][0]).toBe('/ask');
    expect(apiClient.post.mock.calls[1][1]).toEqual({
      document_id: 'test-doc-123',
      question: 'What is the main topic?',
    });
  });
});

2. Mock Service Worker

Use Mock Service Worker (MSW) to intercept and mock API requests:

# Install MSW
npm install --save-dev msw

// src/mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  // Mock document upload endpoint
  rest.post('/api/upload', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        document_id: 'mock-doc-123',
        message: 'Document uploaded successfully',
      })
    );
  }),
  
  // Mock question answering endpoint
  rest.post('/api/ask', (req, res, ctx) => {
    const { question } = req.body;
    
    return res(
      ctx.status(200),
      ctx.json({
        answer: `This is a mock answer to: "${question}"`,
      })
    );
  }),
];

// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/mocks/browser.js (for browser usage)
import { setupWorker } from 'msw';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

// Add to jest.setup.js
import { server } from './src/mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Backend Testing

1. FastAPI Endpoint Testing

Test your FastAPI endpoints using pytest:

# Install pytest and testing dependencies
pip install pytest pytest-asyncio httpx

# api/tests/test_documents.py
import pytest
from fastapi.testclient import TestClient
from ..main import app
from ..services.document_service import process_document

# Mock the document processing service
@pytest.fixture
def mock_process_document(monkeypatch):
    async def mock_process(*args, **kwargs):
        return {"success": True}
    
    monkeypatch.setattr("api.services.document_service.process_document", mock_process)

client = TestClient(app)

def test_upload_document(mock_process_document):
    # Create a test file
    file_content = b"Test document content"
    files = {"file": ("test.pdf", file_content, "application/pdf")}
    
    # Make the request
    response = client.post("/upload", files=files)
    
    # Check the response
    assert response.status_code == 200
    assert "document_id" in response.json()
    assert "message" in response.json()

def test_upload_invalid_file_type():
    # Create an invalid file type
    file_content = b"Invalid file content"
    files = {"file": ("test.exe", file_content, "application/octet-stream")}
    
    # Make the request
    response = client.post("/upload", files=files)
    
    # Check the response
    assert response.status_code == 400
    assert "detail" in response.json()
    assert "Invalid file type" in response.json()["detail"]

# api/tests/test_qa.py
import pytest
from fastapi.testclient import TestClient
from ..main import app
from ..services.llm_service import get_llm_response

# Mock the LLM service
@pytest.fixture
def mock_llm_service(monkeypatch):
    async def mock_get_response(*args, **kwargs):
        return "This is a mock answer from the LLM."
    
    monkeypatch.setattr("api.services.llm_service.get_llm_response", mock_get_response)

client = TestClient(app)

def test_ask_question(mock_llm_service):
    # Prepare the request data
    data = {
        "document_id": "test-doc-123",
        "question": "What is the main topic?"
    }
    
    # Make the request
    response = client.post("/ask", json=data)
    
    # Check the response
    assert response.status_code == 200
    assert "answer" in response.json()
    assert response.json()["answer"] == "This is a mock answer from the LLM."

def test_ask_question_missing_parameters():
    # Missing document_id
    data = {
        "question": "What is the main topic?"
    }
    
    # Make the request
    response = client.post("/ask", json=data)
    
    # Check the response
    assert response.status_code == 400
    assert "detail" in response.json()
    assert "Missing required parameters" in response.json()["detail"]

2. Testing LLM Integration

Test your LLM service integration:

# api/tests/test_llm_service.py
import pytest
from unittest.mock import patch, MagicMock
from ..services.llm_service import get_llm_response, get_llm_client

@pytest.fixture
def mock_llm_client():
    mock_client = MagicMock()
    mock_completion = MagicMock()
    mock_completion.choices = [MagicMock(text="Mock LLM response")]
    mock_client.completions.create.return_value = mock_completion
    
    with patch("api.services.llm_service.get_llm_client", return_value=mock_client):
        yield mock_client

@pytest.mark.asyncio
async def test_get_llm_response(mock_llm_client):
    # Test parameters
    document_id = "test-doc-123"
    document_content = "This is a test document about AI."
    question = "What is the document about?"
    
    # Mock the document retrieval
    with patch("api.services.document_service.get_document", return_value=document_content):
        # Call the function
        response = await get_llm_response(document_id, question)
        
        # Check the response
        assert response == "Mock LLM response"
        
        # Verify the LLM client was called correctly
        mock_llm_client.completions.create.assert_called_once()
        call_args = mock_llm_client.completions.create.call_args[1]
        assert "Document: This is a test document about AI." in call_args["prompt"]
        assert f"Question: {question}" in call_args["prompt"]

End-to-End Testing

1. Setting Up Cypress

Set up Cypress for end-to-end testing:

# Install Cypress
npm install --save-dev cypress

# Add to package.json
"scripts": {
  "cypress:open": "cypress open",
  "cypress:run": "cypress run"
}

# cypress/e2e/document-qa.cy.js
describe('Document Q&A Application', () => {
  beforeEach(() => {
    // Visit the application
    cy.visit('/');
    
    // Intercept API calls
    cy.intercept('POST', '/api/upload', {
      statusCode: 200,
      body: {
        document_id: 'e2e-test-doc-123',
        message: 'Document uploaded successfully',
      },
    }).as('uploadDocument');
    
    cy.intercept('POST', '/api/ask', {
      statusCode: 200,
      body: {
        answer: 'This is a test answer from the E2E test.',
      },
    }).as('askQuestion');
  });

  it('should upload a document and ask a question', () => {
    // Check if the upload component is visible
    cy.contains('h2', 'Upload a Document').should('be.visible');
    
    // Upload a file
    cy.get('input[type="file"]').attachFile('test-document.pdf');
    
    // Wait for the upload to complete
    cy.wait('@uploadDocument');
    
    // Check if the question input is now visible
    cy.contains('h2', 'Ask Questions').should('be.visible');
    
    // Type a question
    cy.get('input[placeholder*="ask a question"]').type('What is the main topic?');
    
    // Submit the question
    cy.contains('button', 'Ask Question').click();
    
    // Wait for the answer
    cy.wait('@askQuestion');
    
    // Check if the answer is displayed
    cy.contains('This is a test answer from the E2E test.').should('be.visible');
  });

  it('should handle file upload errors', () => {
    // Override the upload intercept to return an error
    cy.intercept('POST', '/api/upload', {
      statusCode: 400,
      body: {
        detail: 'Invalid file type',
      },
    }).as('uploadError');
    
    // Upload an invalid file
    cy.get('input[type="file"]').attachFile('invalid-file.exe');
    
    // Wait for the error response
    cy.wait('@uploadError');
    
    // Check if the error message is displayed
    cy.contains('Invalid file type').should('be.visible');
  });
});

2. Visual Regression Testing

Add visual regression testing with Cypress and Percy:

# Install Percy
npm install --save-dev @percy/cypress

# Add to cypress/support/e2e.js
import '@percy/cypress';

# Add to cypress/e2e/visual.cy.js
describe('Visual Regression Tests', () => {
  it('should match the homepage snapshot', () => {
    cy.visit('/');
    cy.percySnapshot('Homepage');
  });

  it('should match the question interface snapshot', () => {
    // Set up the application state
    cy.visit('/');
    
    // Mock the document upload
    cy.window().then((win) => {
      win.localStorage.setItem('documentId', 'visual-test-doc-123');
    });
    
    // Reload to apply the state
    cy.reload();
    
    // Take a snapshot of the question interface
    cy.percySnapshot('Question Interface');
  });
});

Performance Testing

1. Lighthouse CI

Set up Lighthouse CI to test performance metrics:

# Install Lighthouse CI
npm install --save-dev @lhci/cli

# Create lighthouserc.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: ['http://localhost:3000'],
      numberOfRuns: 3,
    },
    upload: {
      target: 'temporary-public-storage',
    },
    assert: {
      preset: 'lighthouse:recommended',
      assertions: {
        'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
        'interactive': ['error', { maxNumericValue: 3500 }],
        'max-potential-fid': ['warn', { maxNumericValue: 300 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'largest-contentful-paint': ['warn', { maxNumericValue: 2500 }],
      },
    },
  },
};

# Add to package.json
"scripts": {
  "lhci": "lhci autorun"
}

2. Load Testing with k6

Set up k6 for load testing your API:

// k6/load-test.js
import http from 'k6/http';
import { sleep, check } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 20 }, // Ramp up to 20 users
    { duration: '1m', target: 20 },  // Stay at 20 users for 1 minute
    { duration: '30s', target: 0 },  // Ramp down to 0 users
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95% of requests should be below 500ms
    http_req_failed: ['rate<0.01'],   // Less than 1% of requests should fail
  },
};

export default function () {
  // Test document upload
  const uploadUrl = 'http://localhost:3000/api/upload';
  const uploadPayload = {
    file: http.file('test-document.pdf', 'application/pdf'),
  };
  
  const uploadRes = http.post(uploadUrl, uploadPayload);
  
  check(uploadRes, {
    'upload status is 200': (r) => r.status === 200,
    'upload has document_id': (r) => JSON.parse(r.body).document_id !== undefined,
  });
  
  // Extract document ID
  const documentId = JSON.parse(uploadRes.body).document_id;
  
  // Test question answering
  const askUrl = 'http://localhost:3000/api/ask';
  const askPayload = JSON.stringify({
    document_id: documentId,
    question: 'What is the main topic?',
  });
  
  const askRes = http.post(askUrl, askPayload, {
    headers: { 'Content-Type': 'application/json' },
  });
  
  check(askRes, {
    'ask status is 200': (r) => r.status === 200,
    'ask has answer': (r) => JSON.parse(r.body).answer !== undefined,
  });
  
  sleep(1);
}

Next Steps

Now that you've implemented comprehensive testing for your Document Q&A application, you can: