Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
aos
GitHub Repository: aos/firecracker
Path: blob/main/tests/framework/report.py
1956 views
1
# Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
# SPDX-License-Identifier: Apache-2.0
3
4
'''
5
This file implements functionality for generating reports.
6
7
A report typically consists of what tests have been executed, the type of test,
8
what functionality it validates, what is the criteria that we use to validate
9
and an optional link to a GitHub issue.
10
11
To pick up all the items listed above, this module defines the `Report` class
12
which is responsible for gathering data on what tests have been executed,
13
extracting test information and generating the report.
14
15
While some items such as test name can be extracted directly from code, others
16
such as test type or issue link are extracted from the function docstring.
17
A test docstring is defined like below:
18
def test_net_device():
19
"""
20
Test description
21
@type: regression
22
@issue: https://github.com/firecracker-microvm/firecracker/issues/1
23
"""
24
25
return "Actual value: 8", "Maximum value: 10"
26
27
On the first line we define the test description. Each subsequent item is
28
defined by specifying a token enclosed by '@' and ':'. None of these tokens
29
is mandatory to be defined in the docstring. If the token is missing, the
30
report may contain the default value or no value at all.
31
32
Since we also want to store what test criteria we're targetting for a test to
33
pass, the framework picks up the values returned by each test to report this
34
data.
35
36
The format used for returning the test criteria is a tuple, with the first
37
element being the actual value or result and the second one being the checked
38
limits.
39
40
For example, for the test above the reported criteria would be
41
'Maximum value: 10'
42
While the test result would be:
43
'Actual value: 8'
44
45
The test framework does not do any comparison here, so it's the users
46
responsibility to properly report these values.
47
'''
48
import datetime
49
import inspect
50
import json
51
import re
52
import subprocess
53
from collections import namedtuple
54
from pathlib import Path
55
from . import mpsing # pylint: disable=relative-beyond-top-level
56
57
# Try to see if we're in a git repo. If yes, set the commit ID
58
COMMIT_ID = ""
59
try:
60
COMMIT_ID = subprocess.check_output(
61
"git rev-parse HEAD",
62
shell=True
63
).decode().strip()
64
except subprocess.CalledProcessError:
65
COMMIT_ID = "Out of tree build"
66
67
68
class Report(mpsing.MultiprocessSingleton):
69
"""
70
Class that holds data about what tests have been executed.
71
72
Generates a test report at the end of the test session.
73
"""
74
75
FNAME_JSON = "test_report.json"
76
77
# Define a container for representing a report item.
78
# It contains the default value, what items it accepts (None if anything is
79
# accepted) and whether the item is filled from the docstring or not.
80
# If not filled from the docstring, the test framework will add the content
81
# through the test results.
82
ReportItem = namedtuple("ReportItem",
83
["value", "from_docstring", "one_of"])
84
85
# Contains a test item name, the value and optionally defines what values
86
# it accepts.
87
# This is where we define what items we look-up in the docstring.
88
doc_items = {
89
# Internal test name representation
90
"name": ReportItem("", False, None),
91
92
# What the test does
93
"description": ReportItem("", True, None),
94
95
# If the test passed, failed or was skipped
96
"outcome": ReportItem("", False, None),
97
98
# How long the test took
99
"duration": ReportItem(0, False, None),
100
101
# What kind of test we're running. We only accept a predefined list
102
# of tests.
103
"type": ReportItem("", True, ["build", "functional", "performance",
104
"security", "style"]),
105
106
# What we take into account to pass a test
107
"criteria": ReportItem("", False, None),
108
109
# Actual result compared to the criteria
110
"result": ReportItem("", False, None),
111
112
# Link to GitHub issue related to this test
113
"issue": ReportItem("", True, None),
114
}
115
116
# A precomputed list of items that we pick up from the docstring.
117
# It's composed of the items containing True as 'from_docscring' in the
118
# doc_items list.
119
# To avoid subsequent regex calls, we only build the regex pattern once.
120
visible_items = \
121
re.compile("(@[%s]+:)" %
122
"|".join([name for name, item in doc_items.items()
123
if item.from_docstring]))
124
125
# Test description is not necessarily specified by a preceding
126
# "@description:" string, so we assume that any string at the beginning
127
# of the docstring is the test description.
128
default_item = "description"
129
130
class TestItem():
131
"""Holds data about one test item."""
132
133
def __init__(self, test_function):
134
"""Parse test function and test report data."""
135
self._test_data = self.parse_data(test_function)
136
137
# Mark the item as not done yet
138
self.done = False
139
140
def finish(self, test_report):
141
"""Mark a test as finished and gather test data."""
142
self._test_data["duration"] = test_report.duration
143
self._test_data["outcome"] = test_report.outcome
144
145
self.done = True
146
147
def set_return(self, data):
148
"""Parse return values and set actual and expected values."""
149
# If test has returned something and it's a tuple, parse it.
150
if isinstance(data, tuple):
151
self._test_data["result"] = str(data[0])
152
self._test_data["criteria"] = str(data[1])
153
154
@staticmethod
155
def parse_data(test):
156
"""
157
Parse data about the given test item.
158
159
We use Report.doc_items to fill out a default dict, then we
160
parse the docstring to gather data.
161
"""
162
# Gets docstring and cleans up the content through `getdoc`
163
data = inspect.getdoc(test.function)
164
165
# Set the default item
166
crt_item = Report.default_item
167
168
# Create a dict with default values
169
found_data = {
170
key: Report.doc_items[key].value
171
for key in Report.doc_items}
172
found_data["name"] = test.nodeid
173
174
# Handle None docstrings
175
if not data:
176
return found_data
177
178
# Split docstring by items enclosed by '@' and ':'.
179
# The point here is to split strings like:
180
# @TOKEN_NAME: VALUE
181
# which results in ['TOKEN_NAME', 'VALUE']
182
docstring_items = {}
183
for item in re.split(Report.visible_items, data.strip()):
184
# Check if the current item is one of the tokens
185
if item[1:-1] in Report.doc_items:
186
crt_item = item[1:-1]
187
continue
188
189
item_value = item.strip()
190
crt_doc_item = Report.doc_items[crt_item]
191
192
# If an item that's not picked up from the docstring is
193
# specified, then continue.
194
if not crt_doc_item.from_docstring:
195
continue
196
197
# Check if we need to validate the item as 'one_of'
198
if crt_doc_item.one_of and \
199
item_value not in crt_doc_item.one_of:
200
raise ValueError(
201
f"{crt_item} must be one of \
202
{crt_doc_item.one_of}, not {item_value}")
203
204
# Check if the item was found twice
205
if crt_item in docstring_items.keys():
206
raise ValueError(f"Item {crt_item} specified twice.")
207
208
docstring_items[crt_item] = item_value
209
210
return {**found_data, **docstring_items}
211
212
def to_json(self):
213
"""Get data ready to be saved as a json."""
214
return self._test_data
215
216
def __init__(self, report_location="test_report/"):
217
"""Initialize a test report object with a given path."""
218
super().__init__()
219
self._mp_singletons = [self]
220
221
self._collected_items = {}
222
self._data_loc = Path(report_location)
223
self._start_time = datetime.datetime.utcnow()
224
225
def add_collected_items(self, items):
226
"""Add to report what items pytest has collected."""
227
for item in items:
228
self._collected_items[item.nodeid] = Report.TestItem(item)
229
230
def catch_return(self, item):
231
"""
232
Wrap around a pytest test function.
233
234
We do this because we want to somehow catch the return value of
235
functions to set expected test criteria and actual criteria.
236
"""
237
# Save the original function
238
original_function = item.obj
239
240
def func_wrapper(*args, **kwargs):
241
# Set the return value of this test item as the return value
242
# of the function
243
self._collected_items[item.nodeid].set_return(
244
original_function(*args, **kwargs))
245
246
item.obj = func_wrapper
247
248
def finish_test_item(self, report):
249
"""Mark a test as finished and update the report."""
250
self._collected_items[report.nodeid].finish(report)
251
self.write_report(self._collected_items)
252
253
def write_report(self, report_items):
254
"""Write test report to disk."""
255
# Sort tests alphabetically and serialize to json
256
self._data_loc.mkdir(exist_ok=True, parents=True)
257
258
# Dump the JSON file
259
with open(self._data_loc / Report.FNAME_JSON, "w") as json_file:
260
total_duration = 0
261
test_items = []
262
for item in report_items.values():
263
# Don't log items marked as not done. An item may be not done
264
# because of a series of reasons, such an item being removed
265
# from the test suite at runtime (e.g. lint tests only executed
266
# on AMD)
267
if not item.done:
268
continue
269
270
item_json = item.to_json()
271
272
test_items.append(item_json)
273
# Add total test duration - does not include overhead from
274
# fixtures and other conftest initializations.
275
total_duration += item_json["duration"]
276
277
json_data = {
278
"commit_id": COMMIT_ID,
279
"start_time_utc": str(self._start_time),
280
"end_time_utc": str(datetime.datetime.utcnow()),
281
"duration": total_duration,
282
"test_items": test_items
283
}
284
json.dump(json_data, json_file, indent=4)
285
286