How to Use GitHub Copilot for Automatic Unit Test Generation - Nayaka Yoga Pradipta
ID | EN

How to Use GitHub Copilot for Automatic Unit Test Generation

Tuesday, Dec 24, 2024

Let’s be honest, writing unit tests is… tedious. We all know testing is important, but when deadlines are tight, tests are often the first casualty to be skipped. This is where GitHub Copilot can be a game changer.

Why is Testing Important (But Often Skipped)?

We’ve all been there: the feature is working, deadline is just hours away, and the choice is between writing tests or pushing first and “testing later.” Spoiler: “later” usually never comes.

Yet, unit tests are like insurance for our code:

  • Prevent regression - If something breaks, you know before users complain
  • Documentation - Good tests serve as behavior documentation
  • Refactor with confidence - Want to change implementation? Just run tests

The problem is, writing tests is repetitive and time-consuming. This is where AI can help.

What is GitHub Copilot?

GitHub Copilot is an AI pair programmer created by GitHub and OpenAI. It can suggest code, autocomplete, even generate whole functions based on context and comments you write.

Setting Up GitHub Copilot

  1. Subscribe to GitHub Copilot - Go to github.com/features/copilot. There’s a 30-day free trial.

  2. Install the VS Code extension:

    • Open VS Code
    • Go to Extensions (Ctrl+Shift+X)
    • Search “GitHub Copilot”
    • Install and sign in with your GitHub account
  3. Verify installation - Try typing a comment in a JavaScript file, Copilot should start giving suggestions.

Copilot for Generating Tests - Basic Example

Okay, let’s get practical. Say we have a simple function:

// utils/math.js
export function calculateDiscount(price, discountPercent) {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error('Invalid discount percentage');
  }
  return price - (price * discountPercent / 100);
}

Now create the test file. All you need to do is:

// utils/math.test.js
import { calculateDiscount } from './math';

// Test calculateDiscount function

Once you type that comment and press Enter, Copilot usually suggests:

describe('calculateDiscount', () => {
  test('should calculate discount correctly', () => {
    expect(calculateDiscount(100, 20)).toBe(80);
  });

  test('should return original price when discount is 0', () => {
    expect(calculateDiscount(100, 0)).toBe(100);
  });

  test('should throw error for negative discount', () => {
    expect(() => calculateDiscount(100, -10)).toThrow('Invalid discount percentage');
  });

  test('should throw error for discount over 100', () => {
    expect(() => calculateDiscount(100, 150)).toThrow('Invalid discount percentage');
  });
});

Pretty good, right? From one comment, we get 4 reasonable test cases.

Generate Tests from Function Signature

A more powerful trick: copy-paste the function signature or even the entire function into the test file, then add a descriptive comment.

// Paste the function to test in a comment
/*
function validateEmail(email) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}
*/

// Generate comprehensive tests for validateEmail
describe('validateEmail', () => {

Copilot will “read” the context from the comment and generate more comprehensive tests:

describe('validateEmail', () => {
  test('should return true for valid email', () => {
    expect(validateEmail('[email protected]')).toBe(true);
  });

  test('should return true for email with subdomain', () => {
    expect(validateEmail('[email protected]')).toBe(true);
  });

  test('should return false for email without @', () => {
    expect(validateEmail('testexample.com')).toBe(false);
  });

  test('should return false for email without domain', () => {
    expect(validateEmail('test@')).toBe(false);
  });

  test('should return false for email with spaces', () => {
    expect(validateEmail('test @example.com')).toBe(false);
  });

  test('should return false for empty string', () => {
    expect(validateEmail('')).toBe(false);
  });
});

Testing Edge Cases with Copilot

Edge cases are where bugs like to hide. Copilot is quite good at identifying edge cases when given the right hints.

Try a prompt like this:

// Test edge cases for calculateDiscount
// - boundary values (0, 100)
// - floating point numbers
// - very large numbers
// - null/undefined inputs

Copilot will generate:

describe('calculateDiscount edge cases', () => {
  test('should handle boundary value 0%', () => {
    expect(calculateDiscount(100, 0)).toBe(100);
  });

  test('should handle boundary value 100%', () => {
    expect(calculateDiscount(100, 100)).toBe(0);
  });

  test('should handle floating point discount', () => {
    expect(calculateDiscount(100, 33.33)).toBeCloseTo(66.67);
  });

  test('should handle very large price', () => {
    expect(calculateDiscount(1000000000, 10)).toBe(900000000);
  });

  test('should handle null price', () => {
    expect(calculateDiscount(null, 10)).toBeNaN();
  });

  test('should handle undefined discount', () => {
    expect(() => calculateDiscount(100, undefined)).toThrow();
  });
});

Jest + React Testing Library Examples

For React components, Copilot can help too. Here’s a simple component:

// components/Button.jsx
export function Button({ label, onClick, disabled = false, variant = 'primary' }) {
  return (
    <button
      className={`btn btn-${variant}`}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
}

In the test file, write:

// components/Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

// Test Button component with all props

Copilot will suggest:

describe('Button', () => {
  test('renders button with label', () => {
    render(<Button label="Click me" onClick={() => {}} />);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  test('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button label="Click me" onClick={handleClick} />);
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('is disabled when disabled prop is true', () => {
    render(<Button label="Click me" onClick={() => {}} disabled />);
    expect(screen.getByText('Click me')).toBeDisabled();
  });

  test('applies correct variant class', () => {
    render(<Button label="Click me" onClick={() => {}} variant="secondary" />);
    expect(screen.getByText('Click me')).toHaveClass('btn-secondary');
  });

  test('does not call onClick when disabled', () => {
    const handleClick = jest.fn();
    render(<Button label="Click me" onClick={handleClick} disabled />);
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).not.toHaveBeenCalled();
  });
});

Tips for Prompting Copilot for Better Tests

Copilot is like a junior developer - it needs clear instructions. Here are effective prompting tips:

1. Be Specific with Test Scenarios

// ❌ Not specific enough
// Test this function

// ✅ Better
// Test fetchUserData:
// - successful API response with user data
// - API returns 404 error
// - API returns 500 error
// - network timeout
// - malformed JSON response

2. Mention Testing Patterns

// Test using AAA pattern (Arrange, Act, Assert)
// Mock fetch API using jest.fn()

3. Specify Expected Behavior

// Test that formatCurrency:
// - adds thousand separators
// - adds '$' prefix
// - handles negative numbers with parentheses
// - rounds to 2 decimal places

4. Include Edge Cases in Comments

// Include tests for:
// - empty array
// - single element
// - duplicate values
// - null/undefined
// - very large arrays (performance)

Real-World Example: Testing API Hooks

Here’s a more real-world example - testing a custom hook for fetching data:

// hooks/useUser.js
import { useState, useEffect } from 'react';

export function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch');
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    fetchUser();
  }, [userId]);

  return { user, loading, error };
}

Prompt for Copilot:

// hooks/useUser.test.js
import { renderHook, waitFor } from '@testing-library/react';
import { useUser } from './useUser';

// Mock fetch API
global.fetch = jest.fn();

// Test useUser hook:
// - initial loading state
// - successful data fetch
// - API error handling
// - userId change triggers refetch

Copilot generates:

describe('useUser', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('should start with loading state', () => {
    fetch.mockImplementation(() => new Promise(() => {}));
    const { result } = renderHook(() => useUser('123'));
    
    expect(result.current.loading).toBe(true);
    expect(result.current.user).toBeNull();
    expect(result.current.error).toBeNull();
  });

  test('should return user data on successful fetch', async () => {
    const mockUser = { id: '123', name: 'John Doe' };
    fetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    });

    const { result } = renderHook(() => useUser('123'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.user).toEqual(mockUser);
    expect(result.current.error).toBeNull();
  });

  test('should handle API error', async () => {
    fetch.mockResolvedValueOnce({
      ok: false,
    });

    const { result } = renderHook(() => useUser('123'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.user).toBeNull();
    expect(result.current.error).toBe('Failed to fetch');
  });

  test('should refetch when userId changes', async () => {
    const mockUser1 = { id: '123', name: 'John' };
    const mockUser2 = { id: '456', name: 'Jane' };

    fetch
      .mockResolvedValueOnce({ ok: true, json: async () => mockUser1 })
      .mockResolvedValueOnce({ ok: true, json: async () => mockUser2 });

    const { result, rerender } = renderHook(
      ({ userId }) => useUser(userId),
      { initialProps: { userId: '123' } }
    );

    await waitFor(() => expect(result.current.user).toEqual(mockUser1));

    rerender({ userId: '456' });

    await waitFor(() => expect(result.current.user).toEqual(mockUser2));
    expect(fetch).toHaveBeenCalledTimes(2);
  });
});

Testing Utilities

Utility functions are usually the easiest to test with Copilot. Example:

// utils/string.js
export function slugify(text) {
  return text
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_-]+/g, '-')
    .replace(/^-+|-+$/g, '');
}
// utils/string.test.js
import { slugify } from './string';

// Comprehensive tests for slugify function
// Include: normal text, special characters, unicode, edge cases

Result:

describe('slugify', () => {
  test('converts text to lowercase', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  test('replaces spaces with hyphens', () => {
    expect(slugify('hello world')).toBe('hello-world');
  });

  test('removes special characters', () => {
    expect(slugify('hello@world!')).toBe('helloworld');
  });

  test('handles multiple spaces', () => {
    expect(slugify('hello   world')).toBe('hello-world');
  });

  test('trims whitespace', () => {
    expect(slugify('  hello world  ')).toBe('hello-world');
  });

  test('removes leading and trailing hyphens', () => {
    expect(slugify('-hello world-')).toBe('hello-world');
  });

  test('handles empty string', () => {
    expect(slugify('')).toBe('');
  });

  test('handles string with only special characters', () => {
    expect(slugify('!@#$%')).toBe('');
  });

  test('handles underscores', () => {
    expect(slugify('hello_world')).toBe('hello-world');
  });

  test('handles mixed separators', () => {
    expect(slugify('hello - world _ test')).toBe('hello-world-test');
  });
});

Limitations and When to Review Manually

Copilot is powerful, but it’s not magic. There are several things to keep in mind:

When Copilot Struggles:

  1. Complex Business Logic - If the logic is complicated and domain-specific, Copilot might miss important scenarios.

  2. Integration Tests - For tests involving multiple systems or complex setup, manual review is more important.

  3. Security-related Tests - Don’t 100% rely on Copilot for security testing. Always review.

  4. Async/Race Conditions - Copilot sometimes generates brittle tests for async code.

Always Review:

  • Test assertions - Make sure expect statements actually test important behavior
  • Mock implementations - Check if mocks reflect real behavior
  • Edge cases - Copilot might miss edge cases specific to your business logic
  • Test isolation - Make sure tests don’t depend on each other

Red Flags:

// 🚩 Test that's too simple/meaningless
test('should exist', () => {
  expect(myFunction).toBeDefined();
});

// 🚩 Test that only tests implementation, not behavior
test('should call internal method', () => {
  expect(internalMethod).toHaveBeenCalled();
});

// 🚩 Hardcoded values without context
test('should return correct value', () => {
  expect(calculate(5)).toBe(25); // Why 25? What's the logic?
});

Here’s the workflow I personally use:

  1. Write the function first - Copilot needs context
  2. Create test file - Make a new .test.js file
  3. Add descriptive comments - List test scenarios you want
  4. Let Copilot generate - Accept suggestions that make sense
  5. Review and modify - Remove irrelevant ones, add missing ones
  6. Run tests - Make sure all pass
  7. Check coverage - Identify gaps and add manual tests

Conclusion

GitHub Copilot can significantly speed up the process of writing unit tests. What used to be tedious because it’s repetitive, can now generate boilerplate in seconds.

But remember - Copilot is a tool, not a replacement for critical thinking. It’s good for:

  • Generating boilerplate test structure
  • Suggesting common test cases
  • Handling repetitive testing patterns
  • Speeding up TDD workflow

But still needs human review for:

  • Business logic validation
  • Security testing
  • Domain-specific edge cases
  • Ensuring test quality

Start with simple functions, then gradually use Copilot for more complex code. Over time, you’ll develop intuition for when Copilot suggestions are good and when they need adjustment.

Happy testing! 🧪