4a - Merging reports
Another core feature of Codecov is our processing and merging of coverage reports. That means we take in any number of reports and aggregate them together, so you can see your coverage data in one view. Let’s see what that looks like.
Write a frontend
Pull the latest from main
and create a new branch step4
git checkout main
git pull
git checkout -b 'step4'
Next, we are going to build out a frontend for our calculator app. We are going to be adding a web
directory with a few files. Create the files by running these commands from the root directory:
mkdir -p web/static/css
mkdir -p web/static/js
touch web/.babelrc
touch web/index.html
touch web/package.json
touch web/server.js
touch web/static/css/calculator.css
touch web/static/js/calculator.js
touch web/static/js/calculatorView.js
Copy and paste the contents below into each file:
{
"env": {
"test": {
"plugins": ["@babel/plugin-transform-modules-commonjs"]
}
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Codecov Server-side Calculator App</title>
<link rel="stylesheet" href="/static/css/calculator.css">
</head>
<body>
<div class="calculator">
<div class="output">
<div data-previous-operand class="previous-operand">123 +</div>
<div data-current-operand class="current-operand">456</div>
</div>
<button class="span-3 disabled"></button>
<button data-all-clear>C</button>
<button data-number>7</button>
<button data-number>8</button>
<button data-number>9</button>
<button data-operation>÷</button>
<button data-number>4</button>
<button data-number>5</button>
<button data-number>6</button>
<button data-operation>*</button>
<button data-number>1</button>
<button data-number>2</button>
<button data-number>3</button>
<button data-operation>-</button>
<button data-number>.</button>
<button data-number>0</button>
<button data-equals>=</button>
<button data-operation>+</button>
</div>
<script type="module" src="/static/js/calculatorView.js"></script>
</body>
</html>
{
"name": "web",
"version": "1.0.0",
"description": "",
"scripts": {
"test": "jest --collectCoverage",
"build": "babel --plugins @babel/plugin-transform-modules-commonjs script.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^0.25.0",
"express": "^4.17.2"
},
"devDependencies": {
"@babel/plugin-transform-modules-commonjs": "^7.16.8",
"jest": "^27.4.7"
}
}
const axios = require('axios')
const express = require("express");
const path = require("path");
const app = express();
const backendHost = 'http://localhost';
const backendPort = '8080';
app.use(express.json());
app.use("/static", express.static(path.resolve(__dirname, "static")));
app.post("/api/:operation", (req, res) => {
axios.post(
backendHost + ':' + backendPort + '/api/' + req.params['operation'],
req.body
).then(response => {
res.json(response.data);
}).catch(error => {
console.log("Error: " + error);
});
});
app.get("/", (req, res) => {
res.sendFile(path.resolve("index.html"));
});
app.listen(process.env.PORT || 3000, () => console.log("Server running..."));
In web/static
, copy and paste the contents below into each file:
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background-color: #ccc;
}
.calculator {
align-content: center;
display: grid;
grid-template-columns: repeat(4, 100px);
grid-template-rows: minmax(120px, auto) repeat(5, 100px);
justify-content: center;
min-height: 100vh;
}
.calculator > button {
background-color: rbga(255, 255, 255, 0.75);
border: 1px solid #FFFFFF;
cursor: pointer;
font-size: 32px;
outline: none;
}
.calculator > button:hover {
background-color: #aaaaaa;
}
.span-3 {
grid-column: span 3;
}
.disabled:hover {
background-color: rbga(255, 255, 255, 0.75);
cursor: not-allowed;
}
.output{
align-items: flex-end;
background-color: rgba(0, 0, 0, 0.75);
display: flex;
flex-direction: column;
grid-column: 1 / -1;
justify-content: space-around;
padding: 10px;
word-break: break-all;
word-wrap: break-word;
}
.output .previous-operand{
color: rgba(255,255, 255, 0.75);
}
.output .current-operand{
color: white;
font-size: 42px;
}
export class Calculator {
constructor(previousOperandTextElement, currentOperandTextElement) {
this.previousOperandTextElement = previousOperandTextElement
this.currentOperandTextElement = currentOperandTextElement
this.clear()
}
clear() {
this.currentOperand = ''
this.previousOperand = ''
this.operation = undefined
this.validOperand = true
}
delete() {
this.currentOperand = this.currentOperand.toString().slice(0, -1)
}
appendNumber(number) {
if (!this.validOperand) return
if (number === '.' && this.currentOperand.includes('.')) return
this.currentOperand = this.currentOperand.toString() + number.toString()
}
chooseOperation(operation) {
if (!this.validOperand) return
if (this.currentOperand === '') return
if (this.previousOperand !== '') {
this.compute()
}
this.operation = operation
this.previousOperand = this.currentOperand
this.currentOperand = ''
}
compute() {
if (!this.validOperand) {
return;
}
const prev = parseFloat(this.previousOperand)
const current = parseFloat(this.currentOperand)
if (isNaN(prev) || isNaN(current)) return
let operation
switch (this.operation) {
case '+':
operation = 'add'
break
case '-':
operation = 'subtract'
break
case '*':
operation = 'multiply'
break
case '÷':
operation = 'divide'
break
default:
return
}
this.callApi(operation)
}
async callApi(operation) {
const response = await fetch("/api/" + operation, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
x: this.previousOperand,
y: this.currentOperand
})
})
if (!response.ok) {
throw new Error("Error: " + response.status);
}
this.currentOperand = await response.json();
this.operation = undefined;
this.previousOperand = '';
this.updateDisplay();
}
getDisplayNumber(number) {
const stringNumber = number.toString()
const integerDigits = parseFloat(stringNumber.split('.')[0])
const decimalDigits = stringNumber.split('.')[1]
let integerDisplay
if (isNaN(integerDigits)) {
integerDisplay = stringNumber;
if (stringNumber.length > 0) {
this.validOperand = false;
}
} else {
integerDisplay = integerDigits.toLocaleString('en', { maximumFractionDigits: 0 })
}
if (decimalDigits != null) {
return `${integerDisplay}.${decimalDigits}`
} else {
return integerDisplay
}
}
updateDisplay() {
this.currentOperandTextElement.innerText =
this.getDisplayNumber(this.currentOperand)
if (this.operation != null) {
this.previousOperandTextElement.innerText =
`${this.getDisplayNumber(this.previousOperand)} ${this.operation}`
} else {
this.previousOperandTextElement.innerText = ''
}
}
}
import { Calculator } from './calculator.js';
const numberButtons = document.querySelectorAll('[data-number]')
const operationButtons = document.querySelectorAll('[data-operation]')
const equalsButton = document.querySelector('[data-equals]')
const allClearButton = document.querySelector('[data-all-clear]')
const previousOperandTextElement = document.querySelector('[data-previous-operand]')
const currentOperandTextElement = document.querySelector('[data-current-operand]')
const calculator = new Calculator(previousOperandTextElement, currentOperandTextElement)
numberButtons.forEach(button => {
button.addEventListener('click', () => {
calculator.appendNumber(button.innerText)
calculator.updateDisplay()
})
})
operationButtons.forEach(button => {
button.addEventListener('click', () => {
calculator.chooseOperation(button.innerText)
calculator.updateDisplay()
})
})
equalsButton.addEventListener('click', button => {
calculator.compute()
})
allClearButton.addEventListener('click', button => {
calculator.clear()
calculator.updateDisplay()
})
document.addEventListener('keydown', function (event) {
let patternForNumbers = /[0-9]/g;
let patternForOperators = /[+\-*\/]/g
if (event.key.match(patternForNumbers)) {
event.preventDefault();
calculator.appendNumber(event.key)
calculator.updateDisplay()
}
if (event.key === '.') {
event.preventDefault();
calculator.appendNumber(event.key)
calculator.updateDisplay()
}
if (event.key.match(patternForOperators)) {
event.preventDefault();
calculator.chooseOperation(event.key)
calculator.updateDisplay()
}
if (event.key === 'Enter' || event.key === '=') {
event.preventDefault();
calculator.compute()
}
});
We can test out our frontend by running
cd web
npm install
node server.js
Be sure to run the backend server as well - in a different terminal:
cd api
flask run --port 8080 --host=0.0.0.0
Go to http://localhost:3000
to view the calculator
Feel free to play around with the interface.
Writing frontend tests
Let’s add tests to our calculator.js
file
touch web/static/js/calculator.test.js
const calc = require('./calculator');
describe('Calculator test suite', () => {
test('calculator clears', () => {
const calculator = new calc.Calculator();
calculator.clear();
expect(calculator.currentOperand).toBe('');
expect(calculator.previousOperand).toBe('');
expect(calculator.operation).toBe(undefined);
expect(calculator.validOperand).toBe(true);
})
test('calculator can input numbers', () => {
const calculator = new calc.Calculator();
calculator.appendNumber(1);
expect(calculator.currentOperand).toBe('1');
calculator.appendNumber(2);
expect(calculator.currentOperand).toBe('12');
})
test('calculator can delete', () => {
const calculator = new calc.Calculator();
calculator.appendNumber(1);
expect(calculator.currentOperand).toBe('1');
calculator.appendNumber(2);
expect(calculator.currentOperand).toBe('12');
calculator.delete();
expect(calculator.currentOperand).toBe('1');
})
})
Run the tests
We can run these tests from the web
directory with the following command
npm run test
and we should see the following output
PASS static/js/calculator.test.js
Calculator test suite
✓ calculator clears (3 ms)
✓ calculator can input numbers
✓ calculator can deletes
---------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------|---------|----------|---------|---------|-------------------
All files | 18.03 | 9.09 | 44.44 | 19.64 |
calculator.js | 18.03 | 9.09 | 44.44 | 19.64 | 26-113
---------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 0.268 s
Add a frontend CI workflow
Let’s add a workflow to run our frontend tests. This workflow does a similar set of steps as our API workflow.
GitLab CI
Go back to the .gitlab-ci.yml
file at the root of your project, add this below the code we added previously for api
frontend:
image: node:latest
script:
- cd web && npm install
- npm run test
- curl -Os https://uploader.codecov.io/latest/linux/codecov
- chmod +x codecov
- ./codecov -t $CODECOV_TOKEN
If we commit this code,
cd .. # back to the root directory
git add .
git commit -m 'step4: add frontend and tests'
git push origin step4
and open a merge request, we see that our code coverage has greatly decreased! With the addition of the new, but poorly tested frontend, our status checks fail.
We also see that in the Codecov comment.
We see this type of situation happen all the time. For example, some projects in a monorepository might be starting code coverage for the first time, while other parts have been using Codecov for months or years. Adding code coverage like this can greatly decrease the percentage for the whole project.
However, what you may not have noticed is that Codecov automatically merges coverage reports together, regardless of the language or CI workflow. This happens under the hood and requires no additional configuration from you.
In the next section, we'll show you how to use Codecov Flags to help group coverage reports and to set different coverage standards for different parts of your project.
Updated 3 months ago