What makes a unit test worth writing

Unit tests are the backbone of reliable software: they catch bugs before they reach production, make refactoring safer, and are essential to Test-Driven Development (TDD). Writing them well, however, takes time—and writing them poorly wastes that time.

A useful unit test starts with intent. Before generating test code, ask what behavior you're verifying and who will rely on these tests later. Will they document requirements for a product owner, guide QA, or give future developers confidence to refactor? Tests written only to hit a coverage percentage are clutter; tests written to protect specific behavior have lasting value.

Your testing philosophy also matters. Do you want isolated tests with mocked dependencies, or broader tests that validate behavior against requirements? Most projects need a mix—but deliberately choosing which approach fits a given module keeps the test suite coherent.

How GitHub Copilot generates tests

GitHub Copilot suggests code in real time as you type, and can also generate tests on demand. The fastest routes: highlight a function or code block, right-click and select Copilot → Generate Tests, or highlight code and run the /tests slash command in Copilot Chat. You can also prompt Copilot Chat in the IDE or on GitHub.com with plain-English requests for test coverage.

Copilot works by reading patterns in your code and context, so the quality of its output tracks the quality of your input. It handles repetitive test scaffolding well—edge cases, input validation, failure paths—and can quickly expand test coverage across a module. The main value is speed: you offload the routine work and spend your energy reviewing and refining what gets generated.

For TDD workflows, Copilot is unusually effective because it will generate tests from a description of functionality that doesn't exist yet. Describe the behavior you want, generate the tests first, then implement code until those tests pass.

Where manual writing still wins

Copilot isn't always the right tool. When you know exactly what tests you want and the process of writing them helps you think through the design—just write them yourself. Many developers find the mechanical act of writing code clarifies their intent. In those cases, use Copilot to extend what you've started rather than to generate the first draft.

Practical tips for better generated tests

Over time, a few habits separate useful Copilot test output from noise:

  • Highlight code before prompting. Copilot generates from context. Highlighting the function you want tested—before right-clicking or using /tests—focuses its attention.
  • Name the specific logic to check. Copilot doesn't read code the way a human does; it recognizes patterns. If an edge case matters, say so explicitly in your prompt: "check the overflow condition in parsePrice."
  • Add context to your code. Comments and docstrings describing expected behavior improve suggestions. Point Copilot at existing tests with the #[file] command to align new tests with your current style.
  • Treat output as a draft. Review Copilot's suggestions like you would any code: run linters, check the logic, and drop tests that don't serve a purpose. LLMs hallucinate and duplicate—verify before committing.
  • Iterate. Generated tests can miss mock objects or mix up assertions. Rephrase the prompt and retry rather than accepting the first output. Ask Copilot "is there anything I'm not testing?"—it frequently surfaces edge cases and error conditions you'd otherwise miss.

Test coverage tools remain essential. Run coverage reports (Jest for JavaScript, Cobertura for Java) over Copilot-generated tests to find untested paths, then prompt Copilot to fill those gaps.

Example: validating prices in Python

Here's a straightforward Python function that validates a price range:

def validate_price(price: float):
 if price <= 0:
   raise ValueError("Price must be greater than 0")
 if price > 1000:
   raise ValueError("Price must be less than or equal to 1000")

Highlight the function and enter this in Copilot Chat:

/tests create tests for validate_price. Check the edge cases as well.

Copilot returns something like this (LLM output is nondeterministic, so your results will vary):

import unittest
from generate_unit_tests import validate_price

class TestValidatePrice(unittest.TestCase):
 def test_price_zero(self):
   with self.assertRaises(ValueError) as context:
     validate_price(0)
   self.assertEqual(str(context.exception), "Price must be greater than 0")

 def test_price_negative(self):
   with self.assertRaises(ValueError) as context:
     validate_price(-10)
   self.assertEqual(str(context.exception), "Price must be greater than 0")

 def test_price_above_limit(self):
   with self.assertRaises(ValueError) as context:
     validate_price(1500)
   self.assertEqual(str(context.exception), "Price must be less than or equal to 1000")

 def test_price_edge_case_zero(self):
   with self.assertRaises(ValueError) as context:
     validate_price(0)
   self.assertEqual(str(context.exception), "Price must be greater than 0")

 def test_price_edge_case_max(self):
   try:
     validate_price(1000)
   except ValueError:
     self.fail("validate_price() raised ValueError unexpectedly!")

if __name__ == '__main__':
 unittest.main()

The generated tests cover the important cases:

  1. test_price_zero checks validate_price(0) raises a ValueError with the message "Price must be greater than 0".
  2. test_price_negative confirms negative values trigger the same error.
  3. test_price_above_limit verifies validate_price(1500) raises "Price must be less than or equal to 1000".
  4. test_price_edge_case_zero duplicates test_price_zero—redundant, safe to remove.
  5. test_price_edge_case_max ensures validate_price(1000) does not raised an exception.

The output works but benefits from a follow-up prompt. Ask Copilot to remove duplicate tests like test_price_edge_case_zero and add coverage for a valid mid-range price such as validate_price(500).

Example: same function with Jest and TypeScript

In TypeScript, the same validation looks like this:

function validatePrice(price: number) {
 if (price < 0) {
   throw new Error('Price must be greater than 0');
 }

 if (price > 1000) {
   throw new Error('Price must be less than 1000');
 }
}

Run the identical prompt against it:

/tests create tests for validate_price. Check the edge cases as well.

The result mirrors the Python tests in Jest syntax:

describe('validatePrice', () => {
 // Valid cases
 it('should accept valid price in middle range', () => {
   expect(() => validatePrice(500)).not.toThrow();
 });

 it('should accept price of 0', () => {
   expect(() => validatePrice(0)).not.toThrow();
 });

 it('should accept price of 1000', () => {
   expect(() => validatePrice(1000)).not.toThrow();
 });

 // Error cases
 it('should throw error for negative price', () => {
   expect(() => validatePrice(-1)).toThrow('Price must be greater than 0');
 });

 it('should throw error for price over 1000', () => {
   expect(() => validatePrice(1001)).toThrow('Price must be less than 1000');
 });

 // Edge cases
 it('should accept small decimal price', () => {
   expect(() => validatePrice(0.01)).not.toThrow();
 });

 it('should accept price close to maximum', () => {
   expect(() => validatePrice(999.99)).not.toThrow();
 });

 it('should throw error for NaN', () => {
   expect(() => validatePrice(NaN)).toThrow('Price must be greater than 0');
 });

 it('should throw error for Infinity', () => {
   expect(() => validatePrice(Infinity)).toThrow('Price must be less than 1000');
 });
});

Copilot matches the framework conventions in use—here, Jest—while covering valid cases, error paths, and boundary conditions for validatePrice. The pattern is the same across languages: review the output, remove redundancy, and add tests for any scenarios missing from the generated set.

Beyond the Basics: Making Copilot a Testing Workhorse

Generating unit tests with GitHub Copilot is a workflow that rewards precision. The shift from a helpful autocomplete to a reliable testing partner often hinges on how you frame your requests and which Copilot features you invoke. When Copilot can see both the target code and a clear specification of what you want to validate, it can produce tests that go far beyond simple happy-path declarations.

For scenarios where the test output misses the mark, the fastest route to a quality suite involves iterative refinement of the prompt itself. If your initial generation produces tests that are shallow, too broad, or focused on implementation details rather than behavior, adjust your language. Asking Copilot to "test the calculateTotal function for edge cases" will yield different, and often better, results than a generic "write tests" instruction.

Strategic Context with Slash Commands

Copilot Chat offers structural conveniences that are easy to overlook. Slash commands such as /tests serve as a shortcut to generate tests for the current selection. They remove ambiguity about the task and can accelerate the drafting process for a class or module. While you may still need to state your expectations for coverage or special cases, the slash command handles the boilerplate of telling the tool to write test code.

For building an entirely new test suite—especially for an unfamiliar codebase—actively guiding Copilot Chat with the right context is essential. You can point it to a specific function, but you can also ask it to look at the broader module. If you want a test that handles a specific error condition or a particular data structure shape, include that in the chat message. This guidance helps the model anticipate conditional branches and avoid generating tests that fail because they don't account for how the code actually behaves.

Interactive Generation and Review

Using Copilot is rarely a one-shot transaction; it’s a conversational loop. After Copilot generates a batch of tests, inspect them critically. Ask follow-up questions in Copilot Chat to have it complete missing scenarios, such as "add a test for an empty list" or "cover the unexpected null input case." This ability to request variations on the fly is where the tool shifts from a code snippet generator to a programming companion tackling edge-case coverage.

The most effective workflows emphasize that prompts must be explicit about the expected behavior. Include your project’s context—such as the framework you use (e.g., Jest or Vitest) or the naming conventions—in your prompt to ensure the output is immediately usable and passes linting. Think of your prompt as a specification for the test, not a mere suggestion. A good prompt defines the unit under test, the dependencies it has, and the criteria for passing.

Remember that the quality of Copilot-generated tests hinges on your willingness to validate the output.

  • Verify assertions: Ensure the toBe or toEqual values match the actual intended outcomes of the code.
  • Review mocks: Check that mocked dependencies are properly injected and that the tests are not passing because they are testing a mocked module rather than the actual logic.

Copilot’s strength lies in handling the structural patterns and tedious mechanics of an extensive suite, but your review ultimately ensures that the generated tests provide value.