This module applies essential software engineering practices to ensure the reliability, maintainability, and scalability of the MLOps pipeline. The codebase is adapted from the Streaming Module in Module 4: Deployment/streaming.
We will progressively enhance the system by implementing each best practice step-by-step.
- Unit Testing and Dockerizing the Streaming Module
- Integration Testing
- Code Quality: Linting and formatting
- Git Pre-commit Hooks
- Workflow Automation with Makefiles
- Infrastructure as Code (IaC) using Terraform
- CI/CD Pipeline Setup
Note: The code is committed after completing each task to ensure incremental progress and traceability. Final changes will reflect a clean and production-ready implementation.
This section covers:
- Writing structured unit tests for the streaming service
- Refactoring code for better testability
- Running and debugging tests locally
- Dockerizing the updated module for portability and consistency
The starting point is the streaming service from Module 4: Deployment/streaming, which is now enhanced with proper test coverage and containerization.
created a tests folder and model_test.py inside that.
Activate and install pipenv
pipenv shell
pipenv install
Make sure you have pytest installed:
pipenv install --dev pytestIn your project root, run:
pipenv run pytest testsOnce you’ve tested and debugged your code, you can containerize it for deployment.
docker build -t stream-model-sales-predictions:v2 .docker run -it --rm \
-p 8080:8080 \
-e PREDICTIONS_STREAM_NAME="sales_predictions" \
-e RUN_ID=${RUN_ID} \
-e S3_BUCKET_NAME=${S3_BUCKET_NAME} \
-e EXP_ID=${EXP_ID} \
-e TEST_RUN="True" \
-e AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}" \
-e AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}" \
-e AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION}" \
stream-model-sales-predictions:v1The
-eflags set environment variables inside the container.
Note: If the exported environment variable are already configured within the code via default value before docker image, then no need to pass it in docker run command
test the container
python test_docker.py- Unit testing helps isolate and validate parts of your code
- Mocking makes testing easier by replacing real models or APIs
- Docker allows you to package and run your tested code in a consistent environment
- Keeping code modular and independent improves testability
pytestfor unit testingDockerfor containerization- AWS environment variables for Kinesis stream simulation
In addition to unit testing, we implemented integration testing to validate the end-to-end functionality of the streaming service inside Docker containers.
Key steps include:
- Downloading the trained model from S3 to load the model locally
- Mounting the model into the container at runtime
- Running the service and validating predictions through tests
- Automating the entire process with a shell script
integration-test/
├── test_docker_test.py # Integration test script
├── run.sh # Automation script to build, run and test
├── docker-compose.yml # Docker Compose file to simplify container setup
└── model/ # Locally downloaded model from S3-
Created
integration-testDirectory A new folderintegration-testwas created, and thetest_docker.pyfile was added using the same code from the unit testing module. -
Downloaded Model from S3 To avoid runtime dependency on S3, we downloaded the model manually with:
aws s3 cp --recursive s3://mlartifact-s3/1/55b250328b3343f0a08b8a97a15707bf/artifacts/model/ model # or run a command to download model locally aws s3 sync s3://mlartifact-s3/6/080e0226c1fc49cc818d3c023625b36d/artifacts/model ./model/Check the size of the model directory:
ls -lh model
-
Created Automation Script:
run.shTo streamline the process, we created a shell script to automate:- Docker image build
- Container run via Docker Compose, added the local model location
- Test execution
- Clean-up
Make the script executable:
chmod +x integration-test/run.sh
Run it with:
./integration-test/run.sh
In the earlier integration test, we validated the model and container behavior but did not test the Kinesis part. This section focuses on testing AWS Kinesis integration using LocalStack, a local AWS cloud emulator.
Before starting integration with Kinesis, we updated the docker-compose.yml file to include a new service for LocalStack, which emulates AWS services locally. We configured it to enable the Kinesis service.
We use Docker Compose to start only the required kinesis service from docker-compose.yaml.
To start just the Kinesis service and test how it works:
docker-compose up kinesis🔹 This will pull the necessary image from Docker Hub and start only the
kinesiscontainer.
Initially, there are no Kinesis streams:
aws kinesis list-streams # targeting the real aws accountOutput:
{
"StreamNames": [],
"StreamSummaries": []
}But this command points to real AWS. To point to LocalStack instead:
aws --endpoint-url=http://localhost:4566 kinesis list-streamsOutput:
{
"StreamNames": []
}To create a new Kinesis stream:
aws --endpoint-url=http://localhost:4566 \
kinesis create-stream \
--stream-name sales_predictions \
--shard-count 1Verify it again:
aws --endpoint-url=http://localhost:4566 kinesis list-streamsOutput:
{
"StreamNames": ["sales_predictions"]
}This confirms that the stream exists only in LocalStack, not in actual AWS.
To redirect the app to LocalStack instead of AWS:
- Define a new environment variable in
docker-compose.yml:
KINESIS_ENDPOINT_URL=http://kinesis:4566/- Update
model.pyand create a method likecreate_kinesis_client()that uses this endpoint.
We:
- Added the stream creation command to
run.sh - Created
test_kinesis.pyto validate the Kinesis stream behavior - Updated
run.shto execute this test after starting services
Run everything with:
./integration-test/run.sh🐳 The Docker container must stay running during Kinesis testing.
We added a new file test_kinesis.py to automate the above steps.
run.sh was updated to:
- Build and run containers
- Create the stream
- Run both
test_docker.pyandtest_kinesis.py
Integration Testing (Model + Kinesis + Container) Run from project root:
./integration-test/run.shAll components — model, environment variables, container, and Kinesis — are now fully tested locally with automation using LocalStack.
To maintain clean, consistent, and production-ready Python code, here I have adopted best practices around linting and formatting.
- Linting helps catch common errors and enforce code style by analyzing code statically.
- Formatting tools automatically structure code to follow standardized conventions like PEP8.
We used tools like pylint, black, and isort, and centralized configuration in pyproject.toml for easier project management.
We'll use pylint for linting. It not only checks for PEP8 compliance but also detects deeper issues like:
- Use of global variables
- Missing docstrings
- Unused arguments or imports
pipenv install --dev pylint
pipenv shellRun pylint on a single file:
pylint model.pyRun on the entire project recursively:
pylint --recursive=y .This command will show warnings such as missing docstrings, extra whitespaces, or unused variables.
Black is an uncompromising Python code formatter. It formats code automatically to follow PEP8. Unlike linters, it changes the code directly.
pipenv install --dev black
black --diff . | less # Show diff only
black -S --diff . | less # Keep single quotes
black . # Apply formatting to all filesUse the -S or --skip-string-normalization flag if you want to avoid auto-converting quotes to double quotes.
isort organizes imports alphabetically and by group. This keeps imports tidy and consistent.
pipenv install --dev isort
isort --diff . | less # Show what changes would be made
isort . # Apply sortingInstead of using multiple config files, modern Python tools prefer a central config file: pyproject.toml.
Example for pylint, black, and isort:
[tool.pylint]
[tool.pylint.message_control]
disable = [
"missing-function-docstring",
"missing-class-docstring",
"missing-final-newline",
"missing-module-docstring",
"too-few-public-methods"
]
[tool.black]
line-length = 88
target-version = ["py39"]
skip-string-normalization = true
[tool.isort]
multi_line_output = 3
length_sort = true
profile = "black"
line_length = 88Before pushing code, make sure:
- All linter and formatter checks pass
- All tests pass
- Exit code is zero
Run:
isort .
black .
pylint --recursive=y .
pytest tests/
echo $? # Should return 0Note: Make sure echo $? should run after every command, because it only return error for previous command. If echo $? returns a non-zero code, Git hooks or CI might block the push.
pylintchecks code for issues and style problemsblackformats code automaticallyisortsorts import statementspyproject.tomlcan be used to configure all tools in one place- Run all tools and tests before pushing code
By combining linting, formatting, and testing, we ensure our code is not just working — but clean, readable, and ready for production!
🔗 Read full guide on linting and formatting →
Git supports hooks, which are scripts that run automatically at certain points in the Git workflow. The pre-commit hook runs before git commit is finalized. These can:
- Prevent bad code from being committed
- Auto-fix issues (e.g., formatting)
- Run tests or linters automatically
We’ll use the pre-commit framework to manage these hooks easily.
In our project, we want to test hooks only on the code folder, which is not a Git repo by default. So, we’ll initialize it as a separate Git repo temporarily:
cd 06-best-practices/code/
git initNow this folder behaves like a standalone repository.
-
Install
pre-commit:pipenv install --dev pre-commit
-
Generate a starter config:
pre-commit --help
pre-commit sample-config
ls -a # in realty it doesn't show the fileLet's generate a config file from sample-config
pre-commit sample-config > .pre-commit-config.yaml- Install the Git hooks:
pre-commit install
We can now add a .gitignore file to exclude files like __pycache__/, .venv/, etc.
Then, let’s stage and commit everything:
git add .
git commit -m "initial commit"Before the commit goes through, the configured hooks will run. For example, you may see it clean up trailing whitespaces or flag large files.
You can inspect the changes made by the hooks with:
git status
git diffThen, commit the new fixes:
git add .
git commit -m "fixes from pre-commit default hooks"Now everything should pass, and Git logs will reflect both commits:
git logWe can configure the pre-commit system to run the following commands automatically before each commit:
isort .
black .
pylint --recursive=y .
pytest tests/Note: Edit your .pre-commit-config.yaml file to include the following hooks for isor, black pylint and pytest. For reference check out the file, I have already added.
-
Stage your files:
git add . -
Commit:
git commit -m "Your message"
The configured tools (isort, black, pylint, pytest) will run automatically before the commit is accepted.
If there are any issues (e.g., formatting errors), the commit will fail and you'll see detailed output. Fix the issues, stage the changes again, and commit.
If you ever want to remove the temporary .git setup:
rm -rf .gitThen reinitialize as needed.
- Git pre-commit hooks help automate checks and enforce code quality.
pre-commitmakes hook management simple and reproducible.- We integrated it with tools like
isort,black,pylint, andpytest. - Now, every commit is guaranteed to pass formatting and testing rules.
🔗 Read full guide on pre-commit hooks →
To automate repetitive tasks like testing, linting, building Docker images, running integration tests, and publishing, we use Makefiles with the make tool.
In the previous module we have perfored unit and integration testing, code linting and formating and and pre-commit hooks, Now it’s time to automate all these tasks with one powerful tool: make and Makefiles.
We define our targets (e.g., test, build, run), and then simply call:
make test
make build
make runThink of it like your project’s mini-orchestrator
Already pre-installed (via Xcode Command Line Tools).
sudo apt update && sudo apt install build-essentialchoco install makeRestart your terminal after installation.
Create a file named Makefile (no extension!) in your project directory — for example, inside 06-best-practices/code.
run:
echo 123Run it with:
make runOutput:
echo 123
123
makefound theruntarget and executed the command inside.
Targets can depend on other targets. For example:
test:
echo "Running tests command"
run: test
echo "Run command is dependent on test first"When you run:
make runOutput:
echo "Running tests command"
Running tests command
echo "Run command is dependent on test first"
Run command is dependent on test first
run depends on test, so it runs test first, we can add multiple dependencies.
Let’s write a Makefile that automates testing, linting, Docker builds, integration tests, and publishing.
LOCAL_TAG := $(shell date +"%Y-%m-%d-%H-%M")
LOCAL_IMAGE_NAME := stream-model-duration:$(LOCAL_TAG)
# Unit tests
test:
pytest tests/
# Linting and formatting
quality_checks:
isort .
black .
pylint --recursive=y .
# Build Docker image (depends on tests and code quality)
build: quality_checks test
docker build -t $(LOCAL_IMAGE_NAME) .
# Integration tests (depends on build)
integration_test: build
LOCAL_IMAGE_NAME=$(LOCAL_IMAGE_NAME) bash integration-test/run.sh
# Publish Docker image (depends on everything)
publish: build integration_test
LOCAL_IMAGE_NAME=$(LOCAL_IMAGE_NAME) bash scripts/publish.sh
# ⚙Project setup
setup:
pipenv install --dev
pre-commit installmake setupmake testmake quality_checksmake buildmake integration_testmake publish- All commands under a target must be indented with a TAB, not spaces.
- The default file is
Makefile(case-sensitive). - Targets run in order of dependency.
- You can create shortcuts for any complex logic using Make.
Happy Automating! 🚀
To provision and manage cloud resources like Kinesis, S3, Lambda, ECR, and IAM, we use Terraform — an Infrastructure as Code (IaC) tool.
Terraform enables us to declaratively define infrastructure using version-controlled code, allowing automated, consistent, and repeatable deployments across environments.
With just a few commands, we can:
- Initialize Terraform:
terraform init - Preview planned changes:
terraform plan - Apply infrastructure updates:
terraform apply - Tear down infrastructure:
terraform destroy
🔗 Read full guide on Terraform and cloud infrastructure setup →
This project includes a fully automated CI/CD pipeline using GitHub Actions, covering:
- Continuous Integration: testing, linting, and integration checks on every pull request.
- Continuous Deployment: automated infrastructure provisioning and model deployment on pushes to
main.







