This blog post shows something I have wanted to achieve for a while: deploying a fully tested static site or a web application and then testing the deployed site.
- Introduction
- The static site
- Building the production site
- Deploying to GitHub Pages
- Local testing
- Local test on CI
- The problem with deployed site
- Testing the deployed site
- Testing the deployed site from CI
- Fixing the deploy url
- Preventing broken deployments
- Tips
- Discussion
- See more
Introduction
My previous attempts used a combination of continuous integration (CI) systems and deployment tools; in all cases the result is a workflow that was unnecessary complex and hard to understand. You can see these attempts for yourself from these blog posts:
- Gatsby Netlify Circle and Cypress uses CircleCI + Netlify and requires webhook back Netlify to Circle on deploy to run a test job
- Record Test Artifacts from any Docker CI uses Docker image to run tests locally, but does not test the deployed site
- Immutable deploys and Cypress uses GitLab CI to run tests locally, then deploys using Zeit Now and tests the deployed site, not too bad, but still a combination of two systems and lots of custom glue code; I am not even sure my tool now-pipeline is even working still against Zeit API
This blog post shows how to deploy a static site or a web apps to GitHub Pages while testing the site three times using GitHub Actions:
- first time running site in development mode
- second time by building the production bundles and serving the site using a static server
- third time after deploying the site to GitHub Pages
Source code: you can find the code for this blog post in bahmutov/triple-tested. You can see the finished (well-tested) version of the site deployed at https://glebbahmutov.com/triple-tested/.
Let's roll!
The static site
As an example, I created a static blog site using VuePress. First, let's install it using NPM
1 | npm init --yes |
In package.json we can add commands to run the local development server and build the production site:
1 | { |
Then I have added a new folder called docs
with a few Markdown files - these files will be converted into HTML pages using VuePress settings from docs/.vuepress
folder.
1 | $ ls -la docs/ |
The README.md
file has the content for the main or index page
1 | # Main page |
The about.md
file has a link back to the main page
1 | # About |
We can start the local development server that bundles the Markdown files and serves the HTML pages using npm run docs:dev
command. The vuepress dev docs
command serves the content at port 8080 by default.
1 | npm run docs:dev |
Open that URL in your browser and find a nice, clean static site
The development server watches the source files, and automatically rebuilds the pages on changes. It also forces the browser to reload the page.
Tip: the above example used relative link [about page](./about)
, but according to VuePress docs you should use the file path with extension [about page](./about.md)
to generate the correct link.
Building the production site
We cannot run the npm run docs:dev
in production though: we need to convert the Markdown into finalized HTML pages. VuePress has the build
command that does the production build and optimizes the HTML, CSS and JavaScript asset bundles.
1 | npm run docs:build |
The build
command prepares the static site for deployment - assuming the hosting platform "simply" serves the produced folder docs/.vuepress/dist
.
1 | $ ls -la docs/.vuepress/dist |
In fact, we can serve the dist
folder ourselves to see how it looks. Let's use a quick static server utility called serve:
1 | npm i -D serve |
Open localhost:5000
in the browser - it should look and work exactly the same way as the development server, and perhaps even faster, since it was optimized for production and does not include the extra development code.
Deploying to GitHub Pages
Now that we have built such a beautiful content, let's host it somewhere. I will use GitHub Page to host. I love using GH Pages for static pages, even this blog you are reading is hosted there! Any public GH repository can push HTML files into gh-pages
branch, and the GH Pages serve it.
I could push the dist
folder to gh-pages branch manually, but I want to automate this process. Thus I have created a GitHub Actions workflow file .github/workflow/ci.yml that checks out the code, installs NPM dependencies, builds the production site and deploys the dist
folder to the gh-pages
branch where GitHub Pages host it:
1 | name: ci |
Note: if you have never used GitHub Actions, spend 30 minutes watching my recent talk GitHub Actions in Action and browse the slides, or read the blog post Trying GitHub Actions.
In the above ci.yml
file we are using 3rd party GitHub action from repo peaceiris/actions-gh-pages and pass it two parameters. The secrets.GITHUB_TOKEN
is created automatically by GitHub workflow; this token allows the action to push a new commit back to the gh-pages
branch if there are changed HTML files. The public_dir
parameter tells the action which folder we want to deploy.
Warning ⚠️: if the first deploy fails, read this issue - there seems to be a bug with GH Pages and the GITHUB_TOKEN
on first deploy. I pushed the code manually once using deploy-gh-pages and my personal GitHub access token GH_TOKEN=... npx deploy-gh-pages ./docs/.vuepress/dist
. After that the ci.yml
was able to push the new commits automatically.
You can see the workflow ci
run at GitHub inside the repo's Actions tab
Before we look at the deployed site, let's go back to the local development mode and make sure it works correctly.
Local testing
In a section above, I have shown navigation from the Main page to the About page and back by clicking on the links.
What if we accidentally change the filename about.md
to info.md
? We will have a broken link, which leads to frustrated users. Let's prevent this by adding an end-to-end Cypress test. First, we will install Cypress test runner and a useful utility start-server-and-test to help run and stop multiple processes.
1 | npm i -D cypress start-server-and-test |
When we work locally we want to run the VuePress develop command, wait for the server to respond at port 8080, then we can open Cypress. Here are the scripts:
1 | { |
Whenever we need to start everything locally, we will use npm run dev
script. Let's write a Cypress test.
1 | /// <reference types="cypress" /> |
The test does not hardcode the URL to visit - instead the command cy.visit('/')
relies on baseUrl
set in the cypress.json
file
1 | { |
Start the development server and open Cypress with NPM script
1 | npm run dev |
The test in spec.js
runs very quickly
Tip: if we only wanted to check the local Markdown links, we could just do so, read the blog post Check links in your Markdown documents. But in this blog post we want to write other kinds of functional tests.
While we are at it, we can verify the "Search" behavior. VuePress automatically adds search widget with page titles. Users can go to a page by typing the title:
Let's write a test. First, we need to select the search input box. Here is the relevant markup:
Tip: I have described how to test VuePress Search widget in details in the blog post VuePress and some Cypress end-to-end testing tips
1 | it('finds About page using search', () => { |
The test runs and confirms the search is working
Local test on CI
Now that we have end-to-end tests, let's run them on GitHub Actions CI. We could extend the ci.yml
with commands to start the development server in the background and run Cypress tests like this:
1 | - name: NPM install 📦 |
Or we could use start-server-and-test
utility on CI by defining one more script:
1 | { |
Where the cypress run
command just runs Cypress headlessly.
1 | - name: NPM install 📦 |
The above approach is imperfect. The action step bahmutov/npm-install@v1
does NOT understand how to cache Cypress binary correctly, leading to slow or failing builds. To greatly simplify running Cypress on GitHub Actions CI, we have written cypress-io/github-action. This action "knows" how to install Cypress and NPM dependencies, cache them, start the server, and run the tests. Let's use this action:
1 | - name: Install and test 📦✅ |
The GitHub Actions execute and pass
We can see the logs from the npm run docs:dev
command followed by the Cypress' terminal output.
Nice, our static site will work correctly when deployed; we have tested it!
Or will it?
The problem with deployed site
We have tested the site on GitHub CI by running the VuePress development server. Then we built the site and pushed the ./docs/.vuepress/dist
folder to GitHub Pages. Everything must be good, right? Let's check.
I have a custom domain glebbahmutov.com
configured with GitHub Pages via CloudFlare. My personal repository bahmutov/bahmutov.github.io is hosted at https://glebbahmutov.com/
and every repo <name>
will be available under https://glebbahmutov.com/<name>/
sub path. Thus we can open https://glebbahmutov.com/triple-tested/ and see our blog.
Let's open that URL ...
Oh no, the site ... does not look good. Click on the "About" page link - we can browser to it. But going to the "Main" page does not work. Instead it redirects to the top level domain https://glebbahmutov.com
.
Open the DevTools Network tab and reload the main page - you will see that only the index.html
really loads, all other links fail to load.
Clicking on any resource shows the full path - and it looks wrong, because it does not include /triple-tested/
folder.
Let's look at the source of the page - notice that all resource links start with /assets/...
, which means they are served from the root of the domain.
Each resource link "forgets" to include the base url of the site - the subfolder /triple-tested
part. We should have read the entire VuePress deployment guide that explains GitHub deployment in details:
Ok, that can be done. But before we fix the problem, we need to
...
write a test for it.
Testing the deployed site
As you saw above, things can go wrong when deploying a site. The problems that arise are outside of source code of the site itself. It was not the link [about page](./about)
from the Main page to the About page that we have tested. It was not the Search widget either. No, instead we have discovered a hosting configuration problem. If instead of our small static site example, we had a complex web application, we could have wrong environment variables, an invalid database connection string, an unreachable proxy address - all the things that worked perfectly locally, but were set up incorrectly in production.
To really validate the deployed production site, we need to run a quick smoke end-to-end test, just to see if the entire stack works together.
Remember how we set the base url to test in cypress.json
? It is pointing at http://localhost:8080
, but we can overwrite it from command line or using an environment variable. Let's see if our current tests catch the errors with the deployed site. Open Cypress GUI locally, but pass the full production site URL:
1 | npx cypress open --config baseUrl=https://glebbahmutov.com/triple-tested |
Then run the same tests against it.
The "finds About page using search" test catches the broken site - because the JavaScript bundle for the search widget did not load.
Tip: notice the 404 HTTP status codes Cypress shows in its terminal output - meaning the page under test fails to load a lot of its resources! That's a red flag.
1 | GET /__cypress/iframes/integration/spec.js 200 1.025 ms - 629 |
Why did the first test "has index and about pages" pass? I remember it had an assertion after navigating back from the "About" to the "Main" page... It should have failed.
1 | cy.contains('a', 'main page').click() |
Hmm, look at the time traveling debugger - the assertion passes, but for a wrong reason - the site's url is https://glebbahmutov.com
that does not match the regular expression /about/
, so the test "thinks" everything is peachy.
You have to be careful about negative assertions like should('not.match', ...)
, because they can be passing but for completely unexpected (and wrong) reasons. Let's add a positive assertion that checks something on the page that only the right Main page has.
1 | cy.contains('a', 'main page').click() |
The test fails correctly.
Tip: Cypress is a functional testing tool. Thus if the elements are present on the page, Cypress will happily click on the buttons, navigate using links, check the DOM and urls, etc. And it won't "notice" that the page's styles are missing, and everything looks broken. For that you need to add visual testing; luckily it not too difficult to do.
Testing the deployed site from CI
We cannot rely on manual testing of the deployed site. We need to automatically test the site from CI after it has been deployed. Since we deploy the site to GitHub Pages from GitHub Actions, let's write another action workflow that would be triggered after the deploy finish. I will create a new file .github/workflows/deployed.yml
with another GitHub workflow. Creating a separate workflow file is a great for keeping your CI files simple to understand and use.
1 | name: deployed |
This workflow only runs when a commit on the master
branch has its status updated. GitHub Pages bot sets this status when it starts building the pages, and when it finishes the deployment. We can use GitHub Actions expressions to skip the job test-deployed-page
unless the event that triggered the workflow tells us the GitHub Pages deploy was a success.
Tip: you can dump the entire GH event object and inspect all the fields in order to write the if: ...
expressions. Here is a dummy workflow job that simply prints the event (it is a huge JSON object)
1 | show-event: |
Let's see this workflow in action. I have added line another one 👆
the docs/README.md
file and have commited and pushed the code.
1 | ... |
1 | git add docs/README.md |
We can see the 3 workflows at GitHub: the original ci.yml
on push
to master
branch, followed by the skipped deployed.yml
workflow (because the event status is pending
), and then the full deployed workflow runs when the page has been deployed.
We can click on the finished deployed
workflow and look at the output of the curl -s https://glebbahmutov.com/triple-tested
command. The site has been updated with new text
Now that we have the workflow that runs AFTER the deploy, let's run Cypress tests there, using the same cypress-io/github-action code, but just pointing the tests at the external site.
1 | - name: show deployed page |
Note: if we only change the deployed.yml
file, the deploy event WILL NOT be triggered - because the VuePress content has stayed the same! Thus we need to change some text in the docs
folder in order to trigger a new deploy and see the tests in action. I will remove another one 👆
line and push again.
The CI workflows shows the deployed
workflow failing - because the tests we have added detect the broken static site
Click on the deployed
workflow to see both Cypress tests failing as expected.
We have automated the deployment and testing the production site. At least now we know when the deployment went badly and corrective measures should be taken.
Fixing the deploy url
After reading the VuePress deploy guide one more time, I open the docs/.vuepress/config.js
file and add the base
parameter:
1 | module.exports = { |
The base
parameter also changes the local url when using npm run docs:dev
script, thus we update cypress.json
(and related start-server-and-wait
parameters)
1 | { |
1 | - name: Install and test 📦✅ |
Let's push the commit, the local tests pass, then the deploy happens. And now the post-deploy Cypress tests pass!
We can check the site at GitHub Pages - it is working.
Great!
Preventing broken deployments
Can we prevent a broken site from being deployed to production in the first place? Could we have caught an invalid or missing base
configuration?
When we tested the site in the section Local test on CI, we used the development VuePress server via vuepress dev docs
command. The served site passed the tests, then we built the production version and deployed it. We then ran the production site - which failed at first. We should have tested the production version before deployment in a way similar to the production site. I have already showed that we can serve the dist
folder using serve module: $ npx serve docs/.vuepress/dist
.
Let's simulate the broken build again by removing the base
property from the file docs/.vuepress/config.js
:
1 | module.exports = { |
To simulate sub path, we can serve the bundle from a nested folder.
1 | build the production site |
Now let's serve the output
folder
1 | npx serve output |
Open http://localhost:5000/triple-tested
in the browser to see exactly the same broken site as we had in production before.
Cypress catches the broken site
1 | CYPRESS_baseUrl=http://localhost:5000/triple-tested npx cypress open |
Let's restore the base
property in file docs/.vuepress/config.js
, build the site, copy the dist
folder into output
and try again.
Great, the tests pass when when we correctly set the base
property. Now we need to make sure we test the production site on CI. Let's add these commands to ci.yml file
1 | name: ci |
The GitHub Actions run and pass.
The deploy went through to GitHub Pages and then was tested the 3rd time
Tips
Before I reach the Discussion section, let me give a couple of tips.
GitHub Actions badges
For each GitHub workflow you can add a badge to README file. In our case we will have two separate workflows:
1 | ![ci](https://github.com/bahmutov/triple-tested/workflows/ci/badge.svg?branch=master) |
If you want each badge to lead immediately to matching workflow, add destination urls
1 | [![ci](https://github.com/bahmutov/triple-tested/workflows/ci/badge.svg?branch=master)](https://github.com/bahmutov/triple-tested/actions?query=workflow%3Aci) |
Environment variables
We can avoid hard-coding the production URL in several places in CI configuration files, and instead can use GitHub environment variables to specify the base url to test.
Instead of
1 | - uses: cypress-io/github-action@v1 |
we will set the CYPRESS_baseUrl
at the job level
1 | name: deployed |
Record to Dashboard
GitHub Actions give you a lot of free resources for public repositories. But storing and viewing the Cypress test artifacts (screenshots on failure, videos of the test runs, test results) is inconvenient. Thus I recommend using Cypress Dashboard to record the tests.
I have set up project to record and stored the CYPRESS_RECORD_KEY
as a secret at GitHub repo settings.
We can set the secret as environment variable in the specific steps that run Cypress tests, and pass record: true
parameter. You can find all parameters in examples inside the cypress-io/github-action repository.
1 | ... |
In the deployed.yml
workflow we similarly record the test results
1 | ... |
You can add a badge to the README.md pointing at the project's dashboard, so users can find the test results. The project Id is stored in cypress.json
file, in our case it is rroimy
.
1 | [![Cypress Dashboard](https://img.shields.io/badge/cypress-dashboard-brightgreen.svg)](https://dashboard.cypress.io/#/projects/rroimy/runs) |
See results for yourself: Cypress Dashboard
For each deployment, we see two runs: one is tagged ci
and and the second one is tagged production
.
In the run tagged ci
, the two sets of tests are in two groups:
Each spec has its video captured, and if there were any failures, there would be screenshots too.
Discussion
I have shown how to build, test and deploy a static site using GitHub Actions and Pages working together. Because many things can go wrong, I have tested the site three times:
- First using the development server that comes with VuePress.
- Second time after building the production version by serving it locally to simulate the sub path hosting
- Third time after deployment to check if the production site is working for my users
While triple testing might seem like an overkill, I hope you see why it was necessary. We ran end-to-end tests after each major code transformation: building the production bundle is such a big change, it was necessary to validate the produced bundle. Deploying the site to GitHub Pages is also a big change, so we want to run tests after that. GitHub Actions allowed us to run Cypress tests in all scenarios.
We did not have to run all tests in every scenario. For example, we could have executed just a few smoke tests after building the production bundles and after the deployment. Read the blog post Use meaningful smoke tests about running a small subset of tests.
Source code: you can find the code for this blog post in bahmutov/triple-tested. You can see the finished (well-tested) version of the site deployed at https://glebbahmutov.com/triple-tested/.
See more
- My other GitHub Actions blog posts are available under the tag /tags/github