How to Use GitHub Copilot to Write Better Unit Tests
GitHub Copilot's autocomplete is fine for boilerplate, but for unit tests specifically, it's actually one of the stronger tools available in 2026. The /tests command in Copilot Chat plus a bit of deliberate prompting can take a function from zero coverage to a solid test suite in minutes. The catch is that Copilot's default suggestions are often too shallow, and you have to know how to push past them.
This guide covers the practical workflow: using /tests, asking for edge cases explicitly, structuring table-driven tests, and recognizing when Copilot's output needs correction.
The /tests command: what it does and what it doesn't do
GitHub Copilot added the /tests slash command in the Chat panel (available in VS Code, JetBrains, and the GitHub.com interface). To use it, open a file, select the function or class you want tested, and open Copilot Chat (Ctrl+Shift+I on Windows/Linux, Cmd+Shift+I on macOS). Then type /tests.
Copilot will generate test cases based on the selected code and place them in a new file or suggest where to add them. In VS Code with the latest Copilot extension (1.260+), it also respects your workspace's existing test framework if it can detect one from package.json or pyproject.toml.
What it does well: happy-path tests, type-correct assertions, and basic null/undefined handling. What it misses: boundary conditions, error propagation, and any logic that depends on external state.
A function like this:
export function clampPercentage(value: number): number {
return Math.max(0, Math.min(100, value));
}
Will get you tests for 50 (returns 50), 0 (returns 0), 100 (returns 100). What it won't generate automatically: -1 (returns 0), 101 (returns 100), NaN (behavior depends on your intended contract), Infinity. Those require explicit prompting.
Prompting for edge cases
After running /tests, follow up in the same Copilot Chat thread. Don't start a new chat; keeping context in the same thread means Copilot remembers the function it just looked at.
A reliable follow-up prompt structure:
Add edge case tests for:
- Negative values
- Values above the maximum
- NaN input
- Infinity and -Infinity
- Values that are exactly on the boundary (0, 100)
Being explicit about what edge cases you care about produces better results than "add more edge cases." Copilot's interpretation of "more" tends toward slight variations on the happy path.
For business logic functions, think about what could go wrong in production: empty arrays, empty strings, null foreign keys, dates in the past, dates far in the future, zero-length loops. List those explicitly.
One thing I've noticed: Copilot sometimes generates tests that are technically correct but test a behavior that doesn't match the intended spec. For example, it might assert that a function throws on NaN when the function actually returns 0 for NaN. Always read the assertions, not just the test names.
Table-driven tests: getting Copilot to generate them
Table-driven tests (sometimes called parameterized tests or data-driven tests) are a pattern where you define a list of input/expected pairs and run one test body over all of them. They're much more maintainable than copying and pasting the same assertion 10 times.
In Jest/Vitest, that looks like this:
it.each([
[50, 50],
[0, 0],
[100, 100],
[-1, 0],
[101, 100],
])("clampPercentage(%i) returns %i", (input, expected) => {
expect(clampPercentage(input)).toBe(expected);
});
Copilot won't generate this pattern by default unless you ask for it. The prompt:
Rewrite the clampPercentage tests as a single it.each table-driven test.
Include all the cases we've covered so far. Use the format: [input, expected].
The table format pays off when you're testing functions with many input combinations. For a price calculation function with discount types, tax rates, and currency rounding, a table test with 15 rows is much cleaner than 15 separate it blocks.
Here's the thing about the table output: Copilot usually gets the test data right but occasionally gets the labels wrong (the string after it.each). Check that the description string actually matches what the test is asserting. A misleading test name is almost as bad as a missing test.
Handling classes and stateful objects
For classes, /tests generates tests that instantiate the object for each test. That's fine for simple cases but can produce brittle tests when setup is expensive. The pattern to request:
Generate tests for the UserSession class. Use a beforeEach block to create a fresh instance.
Use afterEach to clean up any timers or subscriptions.
This produces tests that don't leak state between cases. Copilot without this prompt will sometimes create one shared instance at the top of the file and mutate it across tests, which makes test order matter and failures hard to debug.
For async methods, Copilot handles async/await correctly in most cases. Where it sometimes slips is in error cases: it may use .rejects.toThrow() when the function actually returns a rejected promise without throwing. Always run the tests after generation; don't assume they pass.
When Copilot's suggestions are wrong
There are specific patterns where Copilot generates plausible-looking tests that are actually wrong:
Mocking the wrong thing. When a function imports a module and calls it, Copilot sometimes mocks the local function rather than the imported module. In Jest, jest.mock('./module') at the top of the file is usually correct; jest.spyOn(object, 'method') inside the test is correct for class methods. If the generated mock setup looks off, ask: Is this mock setup correct for Jest? The function imports from './api/client'.
Asserting on implementation details. Copilot will sometimes write expect(mockFunction).toHaveBeenCalledTimes(2) when all you care about is the return value. Tests tied to call counts break when you refactor internals. Replace these with assertions on outcomes unless you specifically need to verify call behavior.
Incorrect async assertion syntax. In Vitest specifically, expect(promise).resolves.toBe(x) needs an await in front: await expect(promise).resolves.toBe(x). Copilot occasionally forgets this. The test will pass either way in some configurations but silently not wait in others.
Type-coerced assertions. expect(result).toBe("5") when result is actually 5 (number). These pass when using toEqual but fail with toBe. Copilot sometimes uses the wrong matcher. Check that number/string/boolean types match what the function actually returns.
A practical workflow for a real file
Here's the sequence I use when adding tests to an untested file:
- Open the file, select all exports.
- Open Copilot Chat, run
/tests. - Review what was generated. Accept it to a test file.
- Run the tests:
npx vitest run src/utils/myFile.test.ts. - Fix any failures (sometimes the generated code has syntax errors in complex type assertions).
- Follow up in chat:
Add edge cases for [list them]. Use it.each where there are multiple similar cases. - Run coverage:
npx vitest run --coverage src/utils/myFile.ts. - If coverage is below 80%, ask:
The branch on line 34 is uncovered. Add a test for that path.
The whole process for a 100-line utility file takes about 15 minutes. For a 300-line class with dependencies, budget 45 minutes including mock setup.
One shortcut: if your project uses a specific test pattern (Arrange-Act-Assert comments, specific describe structure), add a comment at the top of an existing test file and tell Copilot to follow the pattern in that file. Copilot Chat can read open files; reference it: Follow the test structure in src/utils/pricing.test.ts.
Copilot vs writing tests yourself
Copilot is fastest on straightforward pure functions: give it input, check the output, done. It's slower to get right on integration-style tests that need complex mock setups. For those, you'll spend more time correcting the generated mocks than you'd spend writing them yourself.
The honest answer: Copilot gets you to 70% coverage quickly, and the last 30% still takes judgment. That's a good deal for most teams. The posts on how to choose an AI coding agent cover how Copilot compares to other tools for this kind of work if you're still weighing options.
Use /tests as a starting point, not a destination. The generated tests need to be read, not just accepted.