Increase Test Coverage with AI
Automatically generate python tests with GitHub Actions as you make pull requests.
Introduction
This project has been archived
We're no longer updating this project - we recommend checking out our active projects instead!
If you've already completed this project, don't worry! Your documentation is still available for you to check out and share.
If you have any questions, leave a question in our Discord and we'll get back to you ASAP!
โก๏ธ 30 Second Summary
Tests catch bugs before they reach production, but writing them for every single function is time-consuming.
In this project, you'll build a GitHub Actions bot that watches your PRs, analyzes new code using Python's AST module, and generates pytest tests using the Gemini API.
What you'll build
Your bot will use GitHub Actions to run a Python script that analyses code, sends it to Gemini, and commits the generated tests directly back to your PR.
By the end of this project, you'll have:
- ๐งช A Python script that analyzes code and generates pytest tests using AI.
- โ๏ธ A GitHub Actions workflow that commits generated tests directly to PRs.
- ๐ Git operations in CI that handle branching, committing, and pushing.
- ๐ Secret Mission: Coverage-gated test generation that only runs when needed.
Are there any prerequisites?
This project is Part 3 of the CI/CD GitHub Workflow series. It is strongly recommended that you complete Part 1: Your First GitHub Actions Workflow and Part 2: AI Code Review Bot first.
Not sure if this project is right for you? Check if it matches your goals
If you're up for a bit of a challenge, quiz yourself on the key concepts up ahead in this project.
- Part 1: Your First GitHub Actions Workflow
- Part 2: Review GitHub Pull Requests with Gemini
- Part 3: You are here!
- Part 4: Coming soon...
Create the Test Generator Script
We're going to jump straight into it!
In this step, you'll create the Python script that reads code, extracts function signatures, and uses AI to generate (hopefully) meaningful pytest tests.
In this step, you're going to:
- Make sure you're all caught up from Part 1 and Part 2 of this series.
- Create a Python script that analyses code to extract functions
- Send function signatures to Gemini for test generation
- Write generated tests to a test file
Whoa, whoa, whoa! What is a function signature?
A function signature is a concise summary of a piece of "function" (which is a fancy name for 'reusable block of code that performs a specific task').
It tells you what the function is called (its name), what inputs it needs (its parameters, including their types), and the kind of output it will give back (its return type).
But first... have you done Part 1: Your First GitHub Actions AI workflow of this series?
โ๏ธ I've completed Part 1
Epic!
Let's run through a quick checklist as a reminder...just mentally check you have the following:
- Your forked ai-cicd-github repository on GitHub
- The project cloned locally in Cursor
- A working CI workflow in .github/workflows/ci.yml
All set? Excellent!
Next question...
โง I need to catch up
No worries!
Let's get you up to speed with what we did in Part 1: Your First GitHub Actions Workflow.
First, we'll set up the starter repo
Wait - what is a "starter repo"?
This starter GitHub repo is pre-written code for ai-cicd-github. To use this code, you'll need a GitHub account.
So the first big question is....do you already have a GitHub account?
โ๏ธ Yep, I have a GitHub account
You're all set!
Venture onwards brave traveller.
โง Nope, I need to create one
- Head to github.com and click Sign up.
- Sign up with Google, Apple, or fill in the form with your email, password, and username.
- Click Create account and verify your email address.
Great, now you have a GitHub account! Let's fork the starter repo below.
- Head to the starter repository https://github.com/itsnextwork/ai-cicd-github.
- Click the Fork button in the top right.
- Click the green Create fork button.
- Click the green <> Code button
- Look for the copy icon, and copy the URL.
Nice work! Now we need to take this URL and use it to get the code for our app locally.
We'll do this using Cursor.
Do you already have Cursor on your machine?
I already have Cursor downloaded
- Open Cursor from your Applications folder (Mac) or Start menu (Windows).
- Wait for Cursor to fully launch.
- If prompted to update, go ahead and update to the latest version.
Onwards! Let's login.
I don't have Cursor yet
- Navigate to the official Cursor website at cursor.com/downloads
๐ macOS
- Download the macOS installer
Not sure what to choose?
On a Mac you may need to know your chip architecture (e.g., Apple Silicon, Intel) to select the correct version.
You can find this by selecting the Apple Menu > About This Mac.
๐ผ๏ธ Windows
- Download the Windows installer.
- Once the download is complete, open your Downloads folder.
- Click the CursorUserSetup...exe file.
- You might see a security warning since it's an app downloaded from the internet. This is normal!
- Select Run to proceed.
- The setup wizard will start.
- Accept the license agreement.
- Select Next.
- Choose the destination location for the installation. The default is usually fine.
- Select Next.
- Choose the Start Menu folder. Again, the default is fine.
- Select Next.
- In the Select Additional Tasks screen, we recommend keeping the default options checked:
- Register Cursor as an editor for supported file types
- Add to PATH
- Select Next.
- You're now ready to install.
- Select Install.
๐ macOS
- Once the download is complete, open your Downloads folder.
- Click the Cursor .dmg file.
- Then, drag the Cursor application into your Applications folder to complete the installation.
- Open your Applications folder.
- Click on the Cursor icon to open it.
- You might see a security warning since it's an app downloaded from the internet. This is normal!
- Select Open to proceed.
Nice! You've successfully installed Cursor.
๐ผ๏ธ Windows
- Once the installation is complete, ensure Launch Cursor is checked.
- Select Finish.
Nice! You've successfully installed Cursor.
I'm already logged in
Awesome, you're all set to continue!
I already have a Cursor account
Fantastic! This will be speedy.
- Select Log In.
Your browser will open to the Cursor login page.
- Log in to your existing Cursor account.
You should see a Welcome to Cursor page.
Awesome! You've logged in and are ready to go.
New to Cursor!
- When Cursor launches for the first time, you'll see a welcome screen.
- Select Sign up.
- Your browser will open to the Cursor login page.
- Select Sign up at the bottom of the screen to create a new account.
- Create a new account using your email or a social provider like Google or GitHub.
- Let's fill in your details to create the account!
- You'll need to verify your email address.
- Check your inbox for an email from Cursor.
- Enter the verification code.
- After creating your account, Cursor will ask if it can log you into its desktop app.
- Select Yes, Log In to connect your account to the Cursor desktop app.
- Cursor will now guide you through some setup instructions. This should only take a few minutes!
- Select skip and continue for importing settings from VS Code (although you're welcome to import them if you use already use VS Code!)
- Next, customize your theme.
- Select Continue.
- Set your preferred keybindings. VS Code is the default.
- Select Continue.
- You'll also see Cursor's Data Sharing agreement. By default, Cursor learns from your code. You can disable this by enabling Privacy mode via Cursor settings.
- Check the box to proceed.
- Select Continue.
- Finally, review your settings.
- Select Continue.
Awesome! You've logged in and are ready to go.
With Cursor ready and waiting, it's time for us to clone our GitHub repo.
- Press Ctrl/Cmd + Shift + P to open the search bar
- Search and select Git: Clone
- Paste the forked repository URL and press Enter.
- Select a folder to save your project (e.g. Desktop if you're looking for somewhere easy to find!)
- Click Select as Repository Destination.
- Click Open when Cursor asks if you'd like to open the cloned repository.
Getting a 'Git not found' error?
That's totally okay! This just means you don't have Git installed on your computer.
- Head over to git-scm.com/downloads.
- Download and run the installer for your operating system.
- Once installed, restart your IDE and try again.
Still having issues? Troubleshoot here or share your error with the NextWork community!
Our project is here! Fantastic. Let's run it!
To run our app, we'll create a virtual environment with Python.
What is a virtual environment
A virtual environment is a self-contained directory that holds a specific packages you might need to install for a project, keeping them separate from other projects.
Create a Python virtual environment
- Open the integrated Cursor terminal (Ctrl/Cmd + `)
- Enter:
python -m venv venv
What is 'venv'?
venv is simply short for virtual environment. This command creates a virtual environment named venv in your current directory.
- Awesome! We've just created a virtual envionment!
- We can confirm this worked by looking for a new folder named venv/ in your project directory.
- You can check in the side pannel in Cursor, or by running the following command in your terminal:
- For macOS/Linux, use ls, and for Windows, use dir.
- You should see venv in the output.
Help! I'm getting 'Python not found'
No worries, let's troubleshoot!
- "python not found": Download from python.org and check "Add to PATH" during installation. Then restart Cursor.
- "python3" works but "python" doesn't: Try python3 -m venv venv instead.
Get help with Python setup or share your error with the NextWork community!
- With your virtual environment all set up, we can now activate it!
- Run the following code:
๐ macOS/Linux
source venv/bin/activate
๐ผ๏ธ Windows
venv\Scripts\activate
"Running scripts is disabled" error?
Open PowerShell as Administrator and run: Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser. Then try again.
- You should see (venv) appear at the beginning of your terminal prompt.
- Congratulations! You are now working your very own virtual environment. Now let's install our dependencies.
Why do we need to install dependencies?
Think of your virtual environment like a whole new world.
Right now, there's basically nothing in it, so we need to install all the necessary dependencies for this project.
That's the beauty of virtual environments... we install them here so they don't mess up any other projects we have.
- Install dependencies by running this command:
pip install -r requirements.txt
- Nice! Requirements added.
- Next, we'll add some tests to our app - we'll write them manually now so that in our GitHub Actions later, we can set them up to run automatically.
Create a practice test
What practice test are we creating?
We're writing some basic code that takes two numbers, multiplies them, and returns the answer.
This makes it super easy for us to verify if a test passes or fails!
We'll call it the multiply function.
- Open app.py
- Add a multiply function:
def multiply(a: int, b: int) -> int:
"""Multiply two numbers together."""
return a * b
- Open tests/test_app.py
- Add the following tests for multiply:
class TestMultiply:
"""Tests for the multiply function."""
def test_multiply_positive_numbers(self):
assert multiply(3, 4) == 12
def test_multiply_by_zero(self):
assert multiply(5, 0) == 0
def test_multiply_negative_numbers(self):
assert multiply(-2, 3) == -6
- Don't forget to update the import at the top of the file to include multiply:
from app import add, is_even, reverse_string, multiply
- Save your file.
- So far so good. Let's verify if our tests pass!
- Run the following command:
pytest -v
What does the -v flag do?
The -v flag stands for "verbose". It makes pytest show each test name and its result individually, instead of just showing dots for passes and F's for failures.
Woohoo! Passing tests! Last bit, then we're all caught up with Part 1...
Create the CI workflow
What is a CI workflow?
CI stands for Continous Automation. Instead of manually running tests like we did just before, a CI workflow automates the process of checking your code for errors and ensuring tests pass.
๐กHow often does this run?
Every time a change to your code base is pushed or a pull request is made to the main branch. We're aiming for zero errors pushed to production.
- Open the workflows folder inside .github.
- Right-click โ New file... โ Name it ci.yml.
- Enter:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest -v
Don't freak out!
It's not that bad - slow down and read it carefully.
Look at the steps section first. It's simple!
- Checkout code: Fetches your repository's code.
- Set up Python: Configures your Python environment, specifically version 3.11.
- Install dependencies: Installs project dependencies listed in requirements.txt using pip.
- Run tests: Executes your project's tests using pytest with verbose output.
- Open the integrated Cursor terminal (Ctrl/Cmd + `)
- Commit and push everything:
git add .
git commit -m "Add multiply function, tests, and CI workflow"
git push origin main
Help I'm stuck!
Honestly, Git can be really fiddly sometimes. Let's see what it is...
- "Permission denied" or "Authentication failed": Go to github.com/settings/tokens and generate a Personal Access Token (classic) with repo scope. Use this token as your password when Git prompts.
- "Remote rejected": Make sure you're pushing to your forked repo. Check with git remote -v.
Ask for help or share your error with the NextWork community!
- Go to Actions tab on your GitHub repo and enable workflows if prompted.
- Your code is now on GitHub with a working CI pipeline.
And you're officially all caught up on Part 1.
Continue below.
How about Part 2: Review GitHub Pull Requests with Gemini?
โ๏ธ I've completed Part 2
You are obviously a very organised and prepared person.
We like that.
One more quick checklist/reminder... do you remember creating:
- Your Gemini API key from Google AI Studio
- The google-genai package in your requirements.txt
- The AI code review script at scripts/ai_review.py
- Your GEMINI_API_KEY stored as a GitHub secret
- The PR review workflow at .github/workflows/pr-review.yml
If that all sounds semi-familiar, we're ready go build on top of it.
Off we go!
โง I need to catch up
No worries!
Let's get you up to speed with what we did in Part 2: Review GitHub Pull Requests with Gemini.
Get Your Google AI API Key
What's an API key?
An API key is like a password that lets your code talk to an external service. In this case, it lets your Python script send requests to the Gemini API for AI-powered analysis.
- Go to https://aistudio.google.com/ and sign in.
- In the left sidebar, click Get API key.
- Click Create API key in the top right.
- Create a new project and click Create key.
- Copy your API key.
Keep your API key secure!
Don't share your API key publicly or commit it to version control.
Add the Gemini dependency
- Open requirements.txt and add:
google-genai>=1.0.0
- Install the new dependency:
pip install -r requirements.txt
Build the review script
- Create scripts/ai_review.py.
- Add the following code:
from google import genai
import sys
client = genai.Client()
def review_code(diff_text):
"""Send a code diff to Gemini for review."""
prompt = f"""You are an expert code reviewer. Review the following code diff and provide feedback.
Focus on:
1. Security vulnerabilities
2. Bug risks
3. Performance issues
4. Best practice violations
For each issue found, provide:
- Severity: HIGH / MEDIUM / LOW
- Description of the issue
- Suggested fix
If the code looks good, say so.
Code diff to review:
{diff_text}
Provide your review in a clear, structured format."""
response = client.models.generate_content(
model="gemini-2.5-flash", contents=prompt
)
return response.text
if __name__ == "__main__":
if len(sys.argv) > 1:
diff_file = sys.argv[1]
with open(diff_file, "r") as f:
diff_content = f.read()
else:
diff_content = sys.stdin.read()
review = review_code(diff_content)
print(review)
Add your API key as a GitHub secret
How do GitHub Secrets work?
GitHub Secrets are encrypted environment variables stored in your GitHub repository settings. They are never exposed in logs or to forked repos. GitHub simply injects the value at runtime and masks it in all output.
- Go to your forked repository on GitHub โ Settings โ Secrets and variables โ Actions.
- Click New repository secret.
- Set Name to: GEMINI_API_KEY
- Set Secret to: your API key
- Click Add secret.
Create the workflow file
- Create .github/workflows/pr-review.yml:
- Enter the workflow configuration:
name: AI Code Review
on:
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements.txt
- name: Get PR diff
run: |
git diff origin/main...HEAD > pr_diff.txt
- name: Run AI review
id: ai-review
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
REVIEW=$(python scripts/ai_review.py pr_diff.txt)
echo "review<<EOF" >> $GITHUB_OUTPUT
echo "$REVIEW" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Post review comment
uses: actions/github-script@v7
env:
REVIEW: ${{ steps.ai-review.outputs.review }}
with:
script: |
const review = process.env.REVIEW;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## ๐ค AI Code Review\n\n${review}\n\n---\n*Powered by Gemini AI*`
});
Push your changes
git add .
git commit -m "Add AI code review script and PR workflow"
git push origin main
Help I'm stuck!
No worries, let's troubleshoot!
- "Permission denied" or "Authentication failed": Go to github.com/settings/tokens and generate a Personal Access Token (classic) with repo scope. Use this token as your password when Git prompts.
- "Remote rejected": Make sure you're pushing to your forked repo. Check with git remote -v.
Ask for help or share your error with the NextWork community!
And you're officially all caught up on Part 2.
Phew! Continue below.
Create the Test Generator Script
Okay. Let's create our script that will analyze code and generate tests.
- Open Cursor
- Select File โ Open
- Open your project from the previous parts in this series: ai-cicd-github
- In your file directory on the left, right-click scripts
- In the menu, select New File...
- Name your new file: generate_tests.py
- Nice! We're about to paste in a whole bunch of code.
- Let's understand what we want it to do first...
What do we want this code to do?
- Read a file of code.
- Identify different code functions so we can make tests.
- Okay! Here we go!
- Have a go at filling in the blanks for this code block:
import ast
import os
import sys
from google import genai
# Define a function that takes a file path and extracts function info
def [[EXTRACT_NAME="what should you name a function that extracts functions?"]]([[EXTRACT_PARAM="what are you passing in?"]]):
"""Parse a Python file and extract function definitions."""
# Open the file and read its contents into a string
with open(file_path, 'r') as f:
source = [[EXTRACT_READ="how do you read a file's contents?"]]
# Parse the source code into an AST tree
tree = [[EXTRACT_PARSE="what ast function parses source code?"]]
functions = []
# Walk through every node in the AST
for node in ast.walk(tree):
# Check if this node is a function definition
if isinstance(node, [[EXTRACT_NODE="what ast type represents a function?"]]):
func_name = node.name
args = [arg.arg for arg in node.args.args]
docstring = ast.get_docstring(node) or ""
func_source = ast.get_source_segment(source, node)
functions.append({
'name': func_name,
'args': args,
'docstring': docstring,
'source': func_source
})
return functions
Wtf is going on
This code enables us to read a file, identify functions, and then store them in something called a ast (Abstract Syntax Tree).
Instead of reading code as text, the AST represents it as a tree structure where each node is an element of the function (variables, loops, etc.).
Go deeper into why we use AST.
- Did you fill in the blanks?
โ๏ธ Yep, I've filled it in
- Nice work!
- Double check your work with the other tab to compare your version.
โง Nope, I want a hint!
Here's the completed function:
import ast
import os
import sys
from google import genai
def extract_functions(file_path):
"""Parse a Python file and extract function definitions."""
with open(file_path, 'r') as f:
source = f.read()
tree = ast.parse(source)
functions = []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
func_name = node.name
args = [arg.arg for arg in node.args.args]
docstring = ast.get_docstring(node) or ""
func_source = ast.get_source_segment(source, node)
functions.append({
'name': func_name,
'args': args,
'docstring': docstring,
'source': func_source
})
return functions
What does this code do?
- f.read(): Reads the entire file into a string variable.
- ast.parse(source): Converts that string into an AST tree you can traverse.
- ast.FunctionDef: The AST node type that represents a function definition.
- The function returns a list of dictionaries, each containing a function's name, arguments, docstring, and source code.
- Save the file. (Ctrl/Cmd + S)
- Your extract_functions function should look like this:
- Awesome. We have some code that reads files, identifies functions, and stores them safely in a tree structure.
- Now let's generate some tests!
Add the AI Test Generation Function
We'll continue adding to our generate_tests.py file.
Our next little section will make sure we can send code to Gemini and get back some generated tests.
What do we want this code to do?
- Connect to Gemini API using our API key
- Send Gemini your functions that you've identified
- Generate python tests
- Send back the generated python tests
- Alright!
- Look for the extract_functions function in your generate_tests.py file.
- We will be pasting our new code directly underneath extract_functions
- Have a go at filling in the gaps...
- ...then paste in the code:
# Define a function that generates tests for a given function's info
def [[GEN_NAME="what should you name a function that generates tests?"]]([[GEN_PARAM="look at what variable is used below, like func_info['name']"]]):
"""Use Gemini to generate pytest tests for a function."""
# Create a Gemini client using your API key from environment variables
client = genai.Client(api_key=[[GEN_ENV_VAR="how do you get GEMINI_API_KEY from environment?"]])
# Write a multi-line prompt that includes the function details
prompt = f"""Generate pytest tests for this Python function.
Function name: {func_info['name']}
Arguments: {', '.join(func_info['args'])}
Docstring: {func_info['docstring']}
Source code:
{func_info['source']}
Requirements:
1. Generate 3-5 meaningful test cases
2. Include edge cases (empty inputs, None values, etc.)
3. Use descriptive test function names
4. Include assertions that actually test behavior
5. Do NOT generate placeholder tests like assert True
6. Do not use Markdown formatting in the test code. Do not wrap code in markdown code blocks
7. Output must be a valid Python file.
Return ONLY the Python test code, no explanations.
"""
# Send the prompt to the model and get a response
response = client.models.generate_content(
model='[[YOUR_MODEL="which Gemini model should you use?"]]',
contents=[[YOUR_INPUT="what are you passing to the model?"]]
)
# Return just the text from the response
return response.[[YOUR_PROPERTY="what property contains the generated text?"]]
- Did you fill in the blanks?
โ๏ธ Yep, I've filled it in
Great job!
Double check the other tab to compare your version.
โง Nope, I want a hint!
Here's the completed function:
def generate_tests_for_function(func_info):
"""Use Gemini to generate pytest tests for a function."""
client = genai.Client(api_key=os.environ.get('GEMINI_API_KEY'))
prompt = f"""Generate pytest tests for this Python function.
Function name: {func_info['name']}
Arguments: {', '.join(func_info['args'])}
Docstring: {func_info['docstring']}
Source code:
{func_info['source']}
Requirements:
1. Generate 3-5 meaningful test cases
2. Include edge cases (empty inputs, None values, etc.)
3. Use descriptive test function names
4. Include assertions that actually test behavior
5. Do NOT generate placeholder tests like assert True
Return ONLY the Python test code, no explanations.
"""
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=prompt
)
return response.text
Do I spot some prompt engineering in there?
Yes! The prompt is the most important part of this script. A well-crafted prompt tells the AI exactly what you want, with specific requirements like "generate 3-5 test cases" and "include edge cases."
Notice we explicitly say "Do NOT generate placeholder tests" - without this, many AI models will produce lazy tests like assert True that don't actually verify anything.
- Save the file. (Ctrl/Cmd + S)
- Your generate_tests_for_function function should look like this:
- Wowza! This is starting to come together.
- We can read files, identify functions, send them to Gemini, and get back python tests!
- The final piece of the puzzle is to make sure all of this can be called by our GitHub Actions later on.
- To do this, we'll create on last function; main().
Add the Main Execution Logic
Our final function main() will allow us to call the rest of our script, and pass in the changed files from a pull request.
- Try filling in the blanks!
- Then when you're ready, add this code at the bottom of your script.
def main():
"""Main function to generate tests for changed files."""
# Get list of changed Python files from command-line arguments
changed_files = [[YOUR_ARGS="how do you get all arguments after the script name?"]] if len(sys.argv) > 1 else []
if not changed_files:
print("No Python files provided for test generation")
return
all_tests = []
for file_path in changed_files:
# Skip non-Python files
if not file_path.[[YOUR_CHECK="how do you check if it ends with .py?"]]:
continue
# Skip test files
if file_path.[[YOUR_PREFIX="how do you check if it starts with tests/?"]]:
continue
print(f"Analyzing: {file_path}")
functions = [[YOUR_CALL="call your function that extracts functions"]](file_path)
for func in functions:
# Skip private functions (those starting with _)
if func['name'].startswith('_'):
continue
print(f" Generating tests for: {func['name']}")
tests = [[YOUR_GENERATE="call your function that generates tests"]](func)
all_tests.append(f"# Tests for {func['name']} from {file_path}\n{tests}")
if all_tests:
# Create tests directory if it doesn't exist
os.makedirs('tests', exist_ok=True)
test_file = 'tests/test_generated.py'
with open(test_file, 'w') as f:
f.write("import pytest\n\n")
f.write("\n\n".join(all_tests))
print(f"Generated tests written to: {test_file}")
else:
print("No functions found to generate tests for")
# Only run this code when the script is executed directly
if [[YOUR_CONDITION="what special variable equals '__main__' when run directly?"]] == "__main__":
main()
- Did you fill in the blanks?
โ I've filled it in
- Amazing work.
- Check the other tab to compare your version.
๐ก I want a hint!
Here's the completed function:
def main():
"""Main function to generate tests for changed files."""
changed_files = sys.argv[1:] if len(sys.argv) > 1 else []
if not changed_files:
print("No Python files provided for test generation")
return
all_tests = []
for file_path in changed_files:
if not file_path.endswith('.py'):
continue
if file_path.startswith('tests/'):
continue
print(f"Analyzing: {file_path}")
functions = extract_functions(file_path)
for func in functions:
if func['name'].startswith('_'):
continue
print(f" Generating tests for: {func['name']}")
tests = generate_tests_for_function(func)
all_tests.append(f"# Tests for {func['name']} from {file_path}\n{tests}")
if all_tests:
os.makedirs('tests', exist_ok=True)
test_file = 'tests/test_generated.py'
with open(test_file, 'w') as f:
f.write("import pytest\n\n")
f.write("\n\n".join(all_tests))
print(f"Generated tests written to: {test_file}")
else:
print("No functions found to generate tests for")
if __name__ == '__main__':
main()
What does this code do?
- sys.argv[1:]: Gets all command-line arguments after the script name. When GitHub Actions runs python generate_tests.py file1.py file2.py, this gives you ['file1.py', 'file2.py'].
- The function loops through each file, extracts functions, generates tests for each, and writes them all to tests/test_generated.py.
- Save the file. (Ctrl/Cmd + S)
Your main function should look like this:
Step #1 Complete
Your test generator script is ready. Now let's wire it into a GitHub Actions workflow.
Build the Test Generation Workflow
Now let's automate test generation with GitHub Actions! You'll create a workflow that runs on every pull request, generates tests, and commits them back.
In this step, get ready to:
- Create a GitHub Actions workflow for test generation
- Configure git operations to commit back to pull requests
- Prevent infinite loops using CI configuration
Create the Workflow File
Let's create the GitHub Actions workflow that will run your test generator.
- Create a new file: .github/workflows/test-gen.yml
- Add the following workflow configuration.
name: AI Test Generation
on:
pull_request:
types: [opened, synchronize]
paths:
- '**.py'
- '!tests/**'
jobs:
generate-tests:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -r requirements.txt
- name: Get changed Python files
id: changed-files
run: |
FILES=$(git diff --name-only origin/${{ github.base_ref }}..HEAD -- '*.py' | grep -v '^tests/' | tr '\n' ' ')
echo "files=$FILES" >> $GITHUB_OUTPUT
- name: Generate tests
if: steps.changed-files.outputs.files != ''
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
python scripts/generate_tests.py ${{ steps.changed-files.outputs.files }}
- name: Commit generated tests
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git diff --quiet tests/; then
echo "No new tests generated"
exit 0
fi
git add tests/
git commit -m "Auto-generate tests for new code [skip ci]"
git push
Understanding the Workflow
Trigger configuration:
- on: pull_request - Runs when pull requests are opened or updated
- paths filter ensures it only runs when Python files change and excludes test files. We don't want to write tests for our tests, or we'll get stuck in an endless loop!
Checkout configuration:
- ref: ${{ github.head_ref }} - Checks out the pull request branch (not the merge commit)
- fetch-depth: 0 - Gets full history for accurate diff
Git operations:
- Configures a bot user for commits
- Uses [skip ci] in commit message to prevent infinite workflow loops
- Only commits if there are actual changes
What does [skip ci] do?
The [skip ci] tag in a commit message tells GitHub Actions NOT to run workflows for that commit. This prevents infinite loops: without it, the bot's commit would trigger the test-gen workflow, which would generate tests, which would commit, which would trigger the workflow again... forever.
Why configure git user in the workflow?
GitHub Actions runners start with no git identity configured. Before you can commit, you must tell git who you are with git config user.name and git config user.email. We use the standard GitHub bot identity (github-actions[bot]) so commits are clearly marked as automated.
Your workflow file should look like this:
Commit Your Changes
Let's push the new workflow file to GitHub.
- Commit and push everything:
git add .
git commit -m "Add test generation script and workflow"
git push origin main
โ Push succeeded
Your code is now on GitHub and the workflow file is ready.
โ I got an error
"Authentication failed" or "Permission denied":
- Make sure you have push access to the repository
- If using HTTPS, you need a personal access token (not your password)
- If using SSH, make sure your SSH key is added to GitHub
"Remote not found":
- Verify the repository exists on GitHub
- Check the remote URL: git remote -v
Ask for help or share your error with the NextWork community!
Step #2 Complete
Your workflow is live! Now let's test it by creating a pull request.
Verify Tests Run Automatically
It's time to run your test generation workflow. You'll create a pull request and watch AI automatically generate tests for you. Cool!
In this step, get ready to:
- Create a pull request
- Watch your test generation workflow run
- Verify that AI has generated tests for your code
Create a Test Pull Request
Let's create a branch, add a new function, and open a pull request to trigger the bot.
- Create a new branch:
git checkout -b feature/new-function
- Open app.py and add this function at the bottom.
New Python concepts
This function uses recursion - a function that calls itself. Recursion is perfect for problems that can be broken down into smaller versions of the same problem (like factorial: 5! = 5 ร 4!).
- raise ValueError(...): Throws an exception with a custom error message.
- Base case: The condition that stops recursion (here, when n โค 1).
- Recursive case: The function calls itself with a smaller input.
def factorial(n):
"""Calculate the factorial of n."""
# Handle invalid input - factorial isn't defined for negatives
if n < [[YOUR_CONDITION="what values are invalid for factorial?"]]:
raise [[YOUR_EXCEPTION="what exception type for invalid values?"]](
"Factorial not defined for negative numbers"
)
# Base case - stop recursion when n is 0 or 1
if n <= [[YOUR_BASE="what's the base case value?"]]:
return [[YOUR_RESULT="what is 0! and 1!?"]]
# Recursive case - n! = n ร (n-1)!
return n * [[YOUR_RECURSIVE="call factorial with what argument?"]]
โ I've filled it in
Check the other tab to compare your version.
๐ก I want a hint!
Here's the completed function:
def factorial(n):
"""Calculate the factorial of n."""
if n < 0:
raise ValueError("Factorial not defined for negative numbers")
if n <= 1:
return 1
return n * factorial(n - 1)
How does recursion work here?
- factorial(5) returns 5 * factorial(4)
- factorial(4) returns 4 * factorial(3)
- ...and so on until factorial(1) returns 1
- Then it all "unwinds": 1 โ 2 โ 6 โ 24 โ 120
- Save the file. (Ctrl/Cmd + S)
Your factorial function should look like this:
- Commit and push your changes:
git add app.py
git commit -m "Add factorial function"
git push -u origin feature/new-function
- Set your fork as the default repository for the GitHub CLI:
gh repo set-default
When prompted, select your forked repository (e.g., your-username/ai-cicd-github).
- Create the pull request:
gh pr create --title "Add factorial function" --body "Testing AI test generation"
Note
If you don't have the GitHub CLI (gh), go to your repository on GitHub and click "Compare & pull request" instead.
Watch the Bot Work
Now watch the magic happen:
- Go to the Actions tab in your repository.
- You should see the "AI Test Generation" workflow running.
- Click on it to see the progress.
โ Workflow completed successfully
You should see all checks passing on your pull request:
Click on the "AI Test Generation" workflow to see the logs:
The workflow should:
- Check out your pull request branch
- Get the list of changed files
- Generate tests for your new functions
- Commit the tests back to your pull request
Continue to verify the bot's commit below.
โ I got an error
Workflow didn't trigger:
- Make sure you added Python files in the pull request
- Check that .github/workflows/test-gen.yml exists
- Verify the pull request is against the correct base branch
API error:
- Go to Settings โ Secrets โ Actions
- Verify GEMINI_API_KEY is set correctly (no extra spaces)
Push failed:
- Go to Settings โ Actions โ General โ Workflow permissions
- Select "Read and write permissions"
- Check "Allow GitHub Actions to create and approve pull requests"
Get help with GitHub Actions or share your error with the NextWork community!
Verify the Bot's Commit
After the workflow completes:
- Go back to your pull request.
- You should see a new commit from github-actions[bot].
- The commit message will be "Auto-generate tests for new code [skip ci]".
- Click on the commit to see the generated test file:
Why do bot commits appear in pull requests?
When a GitHub Actions workflow pushes to a pull request branch, those commits become part of the pull request just like any commit you'd make locally. This is powerful because it means your automation can modify code, add tests, or fix formatting - and the changes go through the same review process as human code. The bot user makes it clear which commits are automated, and reviewers can approve or request changes just like any other pull request.
Verify CI Runs on Generated Tests
If you have a CI workflow (like a basic test runner), it should trigger on the bot's commit:
- The [skip ci] prevents the test-gen workflow from running again.
- But other workflows (like test runners) will still run.
- Check that the generated tests pass in CI.
Step #3 Complete
Your AI test generation bot is live. Ready for the Secret Mission?
Secret mission
By now, you've built a test generation bot that creates tests for new code and commits them to pull requests. In this secret mission, you'll make it smarter - your bot will only run when coverage would drop below 80%, saving API calls and keeping your CI pipeline efficient.
Only Generate Tests When Coverage Drops
Clean Up Your Resources
Clean Up Your Resources
Nice work completing this project! Let's make sure your secrets are secure.
Security checklist:
- Verify your API key isn't in any committed code
- Check that any .env files are in your .gitignore
Keep bot running
If you want to keep using this bot:
- Keep the workflow file in your repository
- Keep the GEMINI_API_KEY secret configured
- The bot will continue generating tests for new pull requests automatically
This is a good choice if you plan to continue developing or reference this project later.
Disable the bot
If you want to pause the bot without deleting anything:
- Go to your repository's Actions tab
- Click on "AI Test Generation" workflow
- Click the "..." menu and select "Disable workflow"
Your code stays in place. You can re-enable the workflow anytime by clicking "Enable workflow" in the same menu.
Remove everything
To fully remove your test generation bot:
- Delete the repository - Go to Settings โ scroll to "Danger Zone" โ Delete this repository
- Revoke API key - Go to Google AI Studio and delete your key
- Clean up local files - Delete the project folder from your computer
You can always create new API keys later if you want to experiment again.
That's a wrap!
That's a wrap!
You did it! You've built an AI-powered test generation bot that commits tests directly to your pull requests.
What you learned:
- ๐ How the AST module parses Python code into an analyzable tree structure
- ๐ค Using prompt engineering to generate meaningful pytest tests with Gemini
- ๐ Creating GitHub Actions workflows that commit directly back to pull requests
- ๐ Configuring git operations in CI to push automated changes
- ๐ Coverage-based triggering to optimize API usage (Secret Mission)
Ready to quiz yourself? ๐ช
Note
If you're EVER stuck - ask the NextWork community. Students like you are already asking questions about this project.
๐ p.s. Does it say "Still tasks to complete!" at the bottom of the screen?
This means you still have screenshots left to upload, or questions left to answer!
- Press Ctrl+F (Windows) or Command+F (Mac) on your keyboard.
- Search for the text Return to later.
- Jump straight to your incomplete tasks!
- ๐โโ๏ธ Still stuck? Ask the community!