CI/CD Pipeline for Python with BDD and TDD Using Behave, Pytest, and GitLab CI

Dean DidionDean Didion
5 min read

This is the approach I’ve adopted for implementing Continuous Integration and Continuous Deployment (CI/CD) for a Python project using Behavior-Driven Development (BDD) with behave and Test-Driven Development (TDD) principles (albeit without the strict red-green-refactor cycle). This mirrors quite closely the process, we as a team, used for my last Engineering manager job. Additionally, I’ll explain how I’ve set up my pipeline using GitLab CI, ensuring pre-commit checks to maintain code quality and consistency.

Behavior-Driven Development (BDD) with Behave

BDD is central to my development process. I utilize behave to define acceptance criteria in Gherkin syntax, allowing me to capture the expected behavior of the application from a user’s perspective.

I begin by writing acceptance tests before any actual coding, based on the criteria that define what it means for the feature to be “done.” These tests are in Gherkin format, a language that lets me specify scenarios using natural language descriptions, which are easy to understand by non-technical stakeholders.

For instance, a Gherkin file might look like this:

Feature: User Login

  Scenario: Successful login
    Given the user is on the login page
    When they submit valid credentials
    Then they should be redirected to the dashboard

By writing these tests upfront, I’m aligning the development process with the end goals. While this mirrors a test-first approach, it differs from classic TDD as I don’t follow the strict “red-green-refactor” cycle. I don’t necessarily run the tests and refactor at every stage. Instead, I define all acceptance criteria first, ensuring clarity on what success looks like.

Test-Driven Development (TDD) and Pytest

While I don’t stick to the classical red-green-refactor TDD approach, I do write my tests before I implement the actual code logic. I rely on pytest (and pytest-cov) for unit testing, making sure that every feature and its components work as intended.

Here’s how my Pytest tests fit into the development process:

  1. Define the Tests:After writing the BDD acceptance tests, I write unit tests using Pytest.

  2. Code Implementation: I then write the code necessary to make the tests pass.

  3. Test Execution: Pytest ensures the individual components of the code behave correctly.

  4. Continuous Integration: These tests run both locally and in the GitLab CI pipeline (more on that below).

Pre-commit Hooks for Code Quality

To ensure that my codebase remains clean and follows best practices, I use pre-commit hooks. These hooks run a series of checks before the code can be committed and pushed to the repository. This automatic validation process helps catch issues early, such as linting errors, formatting problems, and failing tests.

Here’s how I’ve configured my pre-commit hooks in .pre-commit-config.yaml:

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.3.0
    hooks:
      - id: check-yaml
      - id: end-of-file-fixer
      - id: trailing-whitespace

  - repo: https://github.com/psf/black
    rev: 22.10.0
    hooks:
      - id: black

  - repo: local
    hooks:
      - id: black
        name: Format code with Black
        entry: black .
        language: system
        types: [python]

      - id: flake8
        name: Run Flake8
        entry: flake8 .
        language: system
        types: [python]

      - id: pytest-check
        name: pytest-check
        entry: pytest
        language: system
        pass_filenames: false
        always_run: true

      - id: behave
        name: Run behave tests
        entry: ./run_behave.sh
        language: system
        types: [python]

      - id: pytest-cov
        name: PyTest test coverage
        entry: pytest --cov --cov-fail-under=70
        language: system
        types: [python]

Note: I couldn’t get behave to run naturally, in the end I ended up executing it from a shellscript. It works fine from Gitlab - if anyone can help - much appreciated

  • Black ensures that all Python code is formatted consistently.

  • Flake8 handles linting, identifying any coding style violations.

  • Pytest runs the unit tests before committing the code.

  • Behave tests ensure that the BDD scenarios are validated before any changes are pushed.

This helps catch issues like trailing whitespaces, invalid YAML files, or failing tests before the code even gets to the CI pipeline, making the entire process more robust.

CI/CD Pipeline with Gitlab CI

I’ve set up a GitLab CI/CD pipeline to automate testing and ensure continuous integration. Every time a commit is pushed, the pipeline runs the tests and performs various checks to guarantee that only valid, working code makes it into the main branch.

Here’s how I configured the .gitlab-ci.yml file:

image: python:3.12

stages:
  - test
  - deploy

before_script:
  - python -m venv venv  # Create a virtual environment
  - pip install -r requirements.txt  # Install dependencies before running tests
  - pip list  # Verify dependencies

test:
  stage: test
  script:
    - pip install -r requirements.txt
    - pip list  # Check installed packages
    - pytest  # Run unit tests
    - behave app/features  # Run BDD tests

deploy:
  stage: deploy
  script:
    - echo "Deploy step to be added"

This pipeline runs in two stages:

  1. Test Stage:
    • It installs all required packages and dependencies from the requirements.txt file.
    • It runs Pytest to validate the unit tests.
    • It runs Behave to ensure that the acceptance criteria, defined in Gherkin, are met.

  2. Deploy Stage:
    • This stage is currently a placeholder, but in a real-world scenario, it would handle the deployment process, whether to a staging server, production environment, or elsewhere.

Pre-commit Checks in the Pipeline

Beyond local pre-commit hooks, I also enforce the same checks in the GitLab CI pipeline, ensuring consistency between local development and CI environments.

The pre-commit hooks, such as black, flake8, and pytest, automatically run when pushing code to GitLab, ensuring that no unformatted or untested code can be merged. This makes the development process more reliable and minimizes the chance of introducing errors.

Conclusion

In this project, I’ve adopted a hybrid of BDD and TDD methodologies with an emphasis on automation. Writing acceptance criteria in Gherkin ensures the end goals are well-defined from the start, while defining unit tests with Pytest ensures my code is robust and reliable. Integrating these into a CI/CD pipeline with GitLab guarantees that tests run automatically, pre-commit hooks enforce code quality, and my development workflow remains seamless.

By automating the testing and deployment process, I can focus on building features and writing quality code while ensuring that any changes I make are validated at every step.

0
Subscribe to my newsletter

Read articles from Dean Didion directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Dean Didion
Dean Didion

Nerdy Grandpa with a love for mentoring and all things techy