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 few files to the web
directory. You can do so by running
mkdir -p web/static/css
mkdir -p web/static/js
from the root directory. Since there are multiple files, they are aggregated in tabs below
{
"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 that web/static
folder, you can add the following files
*, *::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.
Go to http://localhost:3000
to view the calculator
Feel free to play around with the interface.
Writing frontend tests
Let’s now add tests to our calculator.js
file
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 deletes', () => {
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 tests locally 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.967 s
Add a frontend CI workflow
Let’s add a step to run our frontend tests
Bitbucket Pipelines
pipelines:
default:
- parallel:
- step:
image: python:3.10.4
name: api
script:
- pip install -r api/requirements.txt
- pytest --cov api/
- curl -Os https://uploader.codecov.io/latest/linux/codecov
- chmod +x codecov
- ./codecov
- step:
image: node:latest
name: frontend
script:
- cd web && npm install && npm run test
- curl -Os https://uploader.codecov.io/latest/linux/codecov
- chmod +x codecov
- ./codecov
This workflow does a similar set of steps as our API workflow. 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 PR, 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 about 1 year ago