My Site Project: Part 4 - Automate the Site Testing
In the previous post, we automated the site deployment to Production. In this part of the series, we’ll delve into automating the site deployment to different environments along with running the tests automatically.
Let’s recap the workflow from the previous posts:
- Build the site with the
jekyll build
command ran on top of a Jekyll container (described in part 1 and in part 2) - Deploy the site to a specified environment (described in the previous post)
- Unless it’s not Production, run the tests in that environment (this part)
Of course, the next step wouldn’t be executed if the previous step failed. In this post, we won’t automate the whole flow, but only the last two parts of it.
We’ll run the tests using Python’s unittest
built-in library.
As you may have guessed, we’ll test our site somewhere safe before we show it to our readers. Hence, we’ll implement environment separation.
In order to do so, we’ll implement environment separation at three levels:
- Dev (or development) environment that we’ll run locally to test our site on our computer
- Production-like (or “staging”) environment to run all the tests in a Production-like setup after we completed building the site
- The Production environment (the money time)
On top of the environment separation, we’ll automate the deployment and testing steps by writing a docker-entrypoint.sh
script, which will be run on top of the deployment
container.
Environments Set Up
If we revisit the environments, we’d discover that we have all the moving parts for the local (Development) environment and the Production one. In the case of the Dev environment, we’ve already created settings to serve our site locally with NGINX.
In regards to the Production environent, we already have the DNS entries in Cloudflare and the S3 bucket set up.
As we stipulated earlier, we’d need to set up a Production-like environment to test our site against right before exposing it to our fellow visitors. We’ll mimic the Production environment by performing the following actions:
- Create an S3 bucket named
staging.yourdomain.com
. You should copy all the settings from thewww.yourdomain.com
bucket you’d created in the previous post. - Add the
staging
DNS entry in Cloudflare that would point to the newly created bucket.
After we’re done configuring the Staging environment, we’re good to go.
Configurations Files
Our site should behave the same way in all the environments. In general, it’s a good pratice our business logic behaves the same way independently of the environments it runs on.
In this series, we’ll use .env
files to store environment-related configs.
As their names suggest, the .env
files contain additional environment variables that can be used per each environment.
We’re using .env
files to store configs, since docker-compose supports passing them as environment configurations during the runtime.
The files would be created under the site’s main directory.
We’ll create a .env
file for each environment:
.env.prod
env.staging
env.dev
Their contents will be as follows:
.env.prod
S3_BUCKET_NAME=www.yourdomain.com
BASE_URL=https://www.yourdomain.com
SERVER_HEADER=server
EXPECTED_SERVER=cloudflare
ENVIRONMENT=production
.env.staging
S3_BUCKET_NAME=staging.yourdomain.com
BASE_URL=https://staging.yourdomain.com
SERVER_HEADER=server
EXPECTED_SERVER=cloudflare
ENVIRONMENT=staging
.env.dev
BASE_URL=http://site
SERVER_HEADER=Server
EXPECTED_SERVER=nginx
ENVIRONMENT=dev
Note that the Dev environment differs from the Staging environment and Staging environment’s configs look identical to the Production ones. This is to show that Production-like (e.g. the Staging) environment should contain identical or almost identical configurations to the Production environment.
This way, we can simulate better how our code behaves in the Production environment.
Preparing the Tests
Prior to devising the tests, we’d need to update the requirements.txt
file in the cicd
directory to include all the needed dependencies needed to perform the tests.
Update the Dependencies
Note the new version of the requirements.txt
file:
boto3==1.20.15
requests==2.26.0
beautifulsoup4==4.10.0
Two dependencies were added in order for us to be able to perform the required tests, i.e.:
beautifulsoup4
- parses the HTML page into an object, which can be navigated by Pythonrequests
- allows to issue HTTP requests in a simple format
The Code
We’ll produce a single file called tests.py
under the cicd
directory. This file will contain all the tests that we’d run.
Here are its contents:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/usr/local/bin/python
import unittest
import requests
import os
import sys
from bs4 import BeautifulSoup
from pathlib import Path
BASE_URL_KEY = 'SITE_BASE_URL'
SERVER_HDR_KEY = 'SERVER_HEADER'
HDR_VALUE_KEY = 'EXPECTED_HEADER_VALUE'
CONTENT_TYPE = 'Content-Type'
base_url = os.environ.get(BASE_URL_KEY)
server_header = os.environ.get(SERVER_HDR_KEY)
header_value = os.environ.get(HDR_VALUE_KEY)
class TestSiteLinks(unittest.TestCase):
def setUp(self):
self.page_response = requests.get(base_url)
def test_ok_response(self):
self.assertEqual(self.page_response.status_code, 200)
def test_server_header(self):
self.assertIn(header_value, self.page_response.headers[server_header])
def test_page_links(self):
soup = BeautifulSoup(self.page_response.text, 'html.parser')
links = [link['href'] for link in soup.find_all('a')]
for link in links:
if 'http' not in link:
req_url = f'{base_url}{link}'
if req_url == base_url+'/': # no need to test the base url twice
continue
with self.subTest(link=req_url):
response = requests.get(req_url)
self.assertEqual(response.status_code, 200)
def test_content_type_header(self):
self.assertIn('html', self.page_response.headers[CONTENT_TYPE])
if __name__ == '__main__':
test_result = unittest.main()
Let’s dissect the script into the following parts:
The Setup
In order to run the tests, we’d first need some data, such as to where send our requests to and what headers to expect. In our case, we’re consuming these configurations from the environment variables provided to the container.
In lines 9-16 of the script, we get all the relevant configurations to use them later in the code.
Afterwards, we set up an input against which we’re going to run the tests.
In line 21, we execute a single request to the site’s base URL, so all of our tests can be run against the response’s contents.
The Tests
The table below lists all the tests in the script:
Method name | What it does |
---|---|
test_ok_response |
Tests if the response code is 200 (OK) |
test_server_header |
Tests that the content is served with the correct server: either NGINX or Cloudflare, depending on the environment |
test_page_links |
Gets all the links from the site by traversing the response’s body with the BeautifulSoup library and tests whether there are dead links |
test_content_type_header |
Tests the main response’s Content-Type header to see whether this is correct and suits the expected MIME type. As you recall, specifying an incorrect Content-Type header will result in unexpected errors in our browser while it tries to render the page(s) and their related content |
The Entrypoint Script
In order to automate the last two parts of the flow (.e.g deploying and testing), we’ll create an entrypoint
script named docker-entrypiint.sh
script. This script will be added under the cicd
directory:
#!/bin/sh
echo "#### Starting the site's CI/CD process ####"
if [ -z "$S3_BUCKET_NAME" ]; then # if there's no bucket name specified, skip the deployment
echo "#### Dev environment. Skipping the site deployment ####"
else
echo "#### Deploying the site ####"
./deployment.py 2> deployment_errors.txt #redirect all the error messages from the deployment to the txt file
deployment_result=$(grep -i "exception" deployment_errors.txt)
if [ -n "$deployment_result" ]; then # if the file's contents aren't empty, there were errors
echo "Deployment failed with the following message(s):"
cat deployment_errors.txt
echo "Stopping the next tests"
exit 1
fi
fi
if [ "$ENVIRONMENT" == "production" ]; then
echo "#### Production environment. Skipping the tests ####"
exit 0
fi
echo "#### Running the tests ####"
./tests.py 2> errors.txt #redirect all the stderr to the errors.txt file
test_result=$(grep -i "failure" errors.txt) #search in the file if the tests failed
if [ -n "$test_result" ]; then
echo "Tests failed with the following message(s):"
cat errors.txt
exit 1
fi
echo "#### Tests passed ####"
As stated in if [ -z "$S3_BUCKET_NAME" ]
, if the bucket name is empty we’re in the Dev environment, so we don’t need to deploy the site and then test it.
Otherwise, the script proceeds with the deployment of the site to a specified S3 bucket.
The following script exceprt determines whether the tests should be run depending on the environment:
if [ "$ENVIRONMENT" == "production" ]; then
echo "#### Production environment. Skipping the tests ####"
exit 0
fi
If the script is still running, it proceeds to executing the tests and prints whether the tests were successfull or not.
Note that in ./deployment.py 2> deployment_errors.txt
and in ./tests.py 2> errors.txt
the error messages printed by both scripts are collected to a text file.
A note about the streams in Linux: there are three streams that every program in Linux can get:
stdin
(marked as0
), from where a program can get its inputstdout
(marked as1
). This where the standard output is written, e.g. the non-error messages that are printed.stderr
(marked is2
). This where all the error messages are printed.
Refer here and here for more info about the standard streams.
The above means that we redirect the messages from the stderr
to (>
) a file.
In the next stage, the deployment script evaluates the contents of the files and determines whether there were errors in the deployment and testing stages respectively.
This is done by grep
ing the files’ contents for expressions, such as “exception” or “failure” which are printed by the Python’s error stacktrace and by the unittest
library respectively.
Finally, the script prints whether the tests succeeded or failed. If the tests failed, it prints the entire contents of the error message(s). This way, you’re able to receive an indication which issues you site has, so you can fix them as quickly as possible.
The Deployment Docker Image
FROM python:3.9.9-alpine3.14
ENV SITE_CONTENTS_PATH=/site
ENV AWS_SHARED_CREDENTIALS_FILE=/deploy/credentials
RUN apk update && apk upgrade
WORKDIR /deploy
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY deployment.py tests.py docker-entrypoint.sh ./
RUN chmod 755 *.py *.sh
ENTRYPOINT ["./docker-entrypoint.sh"]
Here are some differences between the original Dockerfile.deploy
and this version of the file:
- We’ve removed the
S3_BUCKET_NAME
environment variable, since it’s going to be specified in one of the.env
files that we’d pass to thedeployment
container when running thedocker-compose
command. - We’ve added the
tests.py
file that contains all of our tests. - We’ve added the
docker-entrypoint.sh
script and made the container’s entrypoint so it gets executed upon the container initialization, as opposed to thedeployment.py
script.
Now we have the entrypoint script that orhestrates the deploying and testing steps in our flow. In the next part, we’ll discuss how exactly we perform the deployment and testing steps for different environments.
Let’s build the container while we’re in the cicd
directory:
docker build -t sitedeployment -f Dockerfile.deploy .
The Docker Compose File
Let’s review our docker-compose
file under the site’s main directory:
version: "3"
services:
site:
image: nginx:1.21.4-alpine
volumes:
- $PWD/_site:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- 80:80
container_name: site
networks:
- internal
sitebuilder:
image: sitebuilder
volumes:
- $PWD:/site
container_name: sitebuilder
entrypoint: ["jekyll", "build"]
deployment:
image: sitedeployment
volumes:
- $PWD/_site:/site
- ~/.aws/credentials:/deploy/credentials:ro
networks:
- internal
depends_on:
- site
environment:
- S3_BUCKET_NAME=${S3_BUCKET_NAME}
- SITE_BASE_URL=${BASE_URL}
- SERVER_HEADER=${SERVER_HEADER}
- EXPECTED_HEADER_VALUE=${EXPECTED_SERVER}
- ENVIRONMENT=${ENVIRONMENT}
networks:
internal:
As you can notice, the composition of the services and relations between them has not been changed. However some minor changes were made to the file.
Let’s look at them more closely:
- Networks. A new
internal
network was added. Custom bridge networks in Docker support internal DNS resolution. Hence, this why theinternal
network was added. This way, oursite
container can be located by the test script that runs in the local environment. - Four environment variables were added under the
deployment
service.docker-compose
supports environment variables templates. These environment variables’ values will be taken from the.env
files we pass as an argument to thedocker-compose
command. - More info about passing the
.env
files to thedocker-compose
command is available here
Semi-Automating the Flow
Finally, we can run almost the whole CI/CD flow semi-automatically by issuing a series of commands to the shell.
If we need to build the site, we can power up the sitebuilder
container as following:
docker-compose up sitebuilder --abort-on-container-exit --exit-code-from sitebuilder
Note the --abort-on-container-exit
and the --exit-code-from
flags. These flags allow us to pass a container’s exit code to the shell.
You can read more about these flags here. This piece of information would be useful in our subsequent post (stay tuned).
You can ignore the error messages about the missing environment variables, since the sitebuilder
image doesn’t rely upon any environment variables.
The same applies to the site
image mentioned further.
If you want to run and test your site locally:
docker-compose up -d site
Afterwards, this command can be run:
docker-compose --env-file .env.dev up deployment \
--abort-on-container-exit --exit-code-from deployment
After the deployment container has completed running all the tests, you’ll get an indication whether the tests succeeded or failed.
To terminate all the containers and to remove the temporarily created network, run the docker-compose down
command.
In order to deploy and test the site in the Staging environment, run the following command:
docker-compose --env-file .env.staging up deployment \
--abort-on-container-exit --exit-code-from deployment
After the tests succeeded you’d probably want to deploy your site to the Production environment. To do so, just change the env.staging
file to env.prod
file under the --env-file
option.
In order to remove all the containers and associated temporary elements, run the docker-compose
command.
The source code is available here.
Stay tuned for more posts.