Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DOC: Adding testing guidelines for RocketPy #626

Merged
merged 4 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/development/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Contributing to RocketPy
Running RocketPy as a Developer <rocketpy_as_developer.rst>
GitHub Workflow for RocketPy Hackathon 2022 <github_hackathon.rst>
Style Guide <style_guide>
Testing Guidelines <testing.rst>
RocketPy with Docker <docker.rst>
Building the Documentation <build_docs.rst>

Expand Down
290 changes: 290 additions & 0 deletions docs/development/testing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
Testing Guidelines
==================

This page describes the testing philosophy used throughout RocketPy's development with Pytest. That includes the definition
and some technical discussion regarding:

* Testing philosophy and style
* Testing naming conventions
* Directory structure
* Unit tests definition
* Integration tests definition
* Acceptance tests definition

However, some other topics such as naming conventions are going to be treated.

Testing philosophy and style
----------------------------

First of all, it is worth noting the role of tests within the framework of RocketPy. Developers must be aware that:

* Unit tests are the minimum requirement for a feature to be accepted.

That is, for each feature must correspond a testing unit which properly documents and tests the newly implemented feature.
In even more practical terms that means the Pull Request containing the feature should include an unit test together with it.

Testing Naming Conventions
--------------------------

Consider the following test naming:

.. code-block:: python

def test_add_motor_coordinates(
calisto_motorless,
cdm_position,
grain_cm_position,
nozzle_position,
coord_direction,
motor_position,
expected_motor_cdm,
expected_motor_cpp,
):

RocketPy Team agreed upon following the testing convention where it's name exactly follows one of those:

* ``test_methodname``
* ``test_methodname_stateundertest``
* ``test_methodname_expectedbehaviour``

However, in any case, it is of utmost importance that the expected behaviour or state to be tested
**should be included within the docstring of the test**, just as illustrated below by the docstring
of the same test:

"""Test the method add_motor and related position properties in a Rocket
GabrielBarberini marked this conversation as resolved.
Show resolved Hide resolved
instance.

This test checks the correctness of the `add_motor` method and the computed
`motor_center_of_dry_mass_position` and `center_of_propellant_position`
properties in the `Rocket` class using various parameters related to the
motor's position, nozzle's position, and other related coordinates.
Different scenarios are tested using parameterization, checking scenarios
moving from the nozzle to the combustion chamber and vice versa, and with
various specific physical and geometrical characteristics of the motor.


Do not get caught by the size of that docstring. The only requirements it has to satisfy is
that the docstring contains precise information on the expected behaviour and/or behaviour
to be tested.

Directory Structure
-------------------

RocketPy organizes its tests as follows:

::

tests/
├── acceptance/
│ ├── acceptance_file_1.py
│ └── acceptance_file_2.py
├── fixtures/
│ ├── fixtures_file_1.py
│ └── fixtures_file_2.py
├── integration/
│ ├── integration_file_1.py
│ └── integration_file_2.py
└── unit/
├── unit_file_1.py
├── unit_file_2.py
└── stochastic/
├── stochastic_file_1.py
└── stochastic_file_2.py

As one might guess, each kind of test should be included within it's correspondent kind of test. For instance, if one is writing
an unit testing module called ``test_flight.py``, it should be included within the ``unit`` folder. The same holds for other tests.
For a more detailed treatment of the directory containing the fixtures, read the next section.

Fixtures
--------

Fixtures play a significant role within testing. In RocketPy it is no different. In fact, so many features are needed
to properly test the code that the RocketPy Team decided to organize them a little different then one might find in
small projects. The directory is structured as follows:

::

tests/
├── fixtures/
│ ├── acceptance/
│ ├── airfoils/
│ ├── environment/
│ ├── flight/
│ ├── function/
│ ├── hybrid/
│ ├── monte_carlo/
│ ├── motor/
│ ├── parachutes/
│ ├── rockets/
│ ├── surfaces/
│ ├── units/
│ └── utilities/

Rocketpy Team opted for this kind of structure since it allowed for a more convenient way of organizing
fixtures. Additionally, it serves the purpose of putting the tests in a position where only strictly needed
fixtures are imported.

**Important:** If a new module containing fixtures is to be created, do not forget to look for the
``conftest.py`` file within the tests folder to include your newly created module.
This file contains, besides the rest, something that looks like:
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved

.. code-block:: python

import pytest

# Pytest configuration
pytest_plugins = [
"tests.fixtures.environment.environment_fixtures",
"tests.fixtures.flight.flight_fixtures",
"tests.fixtures.function.function_fixtures",
"tests.fixtures.motor.liquid_fixtures",
"tests.fixtures.motor.hybrid_fixtures",
"tests.fixtures.motor.solid_motor_fixtures",
"tests.fixtures.motor.tanks_fixtures",
"tests.fixtures.motor.generic_motor_fixtures",
"tests.fixtures.parachutes.parachute_fixtures",
"tests.fixtures.rockets.rocket_fixtures",
"tests.fixtures.surfaces.surface_fixtures",
"tests.fixtures.units.numerical_fixtures",
"tests.fixtures.monte_carlo.monte_carlo_fixtures",
"tests.fixtures.monte_carlo.stochastic_fixtures",
"tests.fixtures.monte_carlo.stochastic_motors_fixtures",
]

Pay close attention to the organization of the plugins, they are also organized in a way
to help the developer position the fixtures in a certain pattern.

To finish, let's take a quick look inside the tests directory structure. Consider the **motor**
folder containing its fixtures:

.. code-block:: rst

motor/
├── __init__.py
├── Cesaroni_M1670_shifted.eng
├── Cesaroni_M1670.eng
├── generic_motor_fixtures.py
├── hybrid_fixtures.py
├── liquid_fixtures.py
├── solid_motor_fixtures.py
└── tanks_fixtures.py

Observe the naming convention for the fixtures within the modules and also how the fixtures were
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved
structured, such that each kind of motor contains a module loaded with its needed fixtures:

* kindofmotor_fixtures.py

One is expected to follow this convention.

Unit tests definition
---------------------

Within a complex code such as RocketPy, some definitions or agreements need to be reviewed or sophisticated
to make sense within a projec. In RocketPy, unit tests are/can be **sociable**, which **still** means that:

* (Speed) They have to be **fast**.
* (Isolated behavior) They focus on a **small part** of the system. Here, we focus on methods.
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved

*However*, as already said, they are/can be sociable:

* (Sociable) The tested unit relies on other units to fulfill its behavior.

The classification depends on whether the test isolates the unit under test from its dependencies or allows them
to interact naturally. In practical terms, consider the test:

.. code-block:: python

def test_evaluate_total_mass(calisto_motorless):
"""Tests the evaluate_total_mass method of the Rocket class.
Both with respect to return instances and expected behaviour.

Parameters
----------
calisto_motorless : Rocket instance
A predefined instance of a Rocket without a motor, used as a base for testing.
"""
assert isinstance(calisto_motorless.evaluate_total_mass(), Function)

This test is **sociable** because it relies on the actual Rocket instance and tests its real behavior without
isolating the Rocket class from its potential interactions with other classes or methods within its implementation.
It checks the real implementation of ``evaluate_total_mass`` rather than a mocked or stubbed version, ensuring that the
functionality being tested is part of the integrated system.

Please note that writing an unit test which is solitary is **allowed**. The classification regarding solitary and
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved
sociable tests was clarified due to the specific needs developers naturally encountered within the software,
while also hoping that since the developers had the need to further identify them, external contributors would probably
fall into the same problem.

Integration tests definition
----------------------------

Integration tests verify that individual modules or components of a software system work together as expected.
Unlike unit tests that isolate specific units of code, integration tests contain an interesting feature:

* (Non-isolated behavior) Focus on interactions between different parts of the system, such as modules, services, databases, or external
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved
APIs.

Consider the following integration test:

.. code-block:: python

@patch("matplotlib.pyplot.show")
def test_wyoming_sounding_atmosphere(mock_show, example_plain_env):
"""Tests the Wyoming sounding model in the environment object.

Parameters
----------
mock_show : mock
Mock object to replace matplotlib.pyplot.show() method.
example_plain_env : rocketpy.Environment
Example environment object to be tested.
"""
# TODO:: this should be added to the set_atmospheric_model() method as a
# "file" option, instead of receiving the URL as a string.
URL = "http://weather.uwyo.edu/cgi-bin/sounding?region=samer&TYPE=TEXT%3ALIST&YEAR=2019&MONTH=02&FROM=0500&TO=0512&STNM=83779"
# give it at least 5 times to try to download the file
example_plain_env.set_atmospheric_model(type="wyoming_sounding", file=URL)

assert example_plain_env.all_info() == None
assert abs(example_plain_env.pressure(0) - 93600.0) < 1e-8
assert (
abs(example_plain_env.barometric_height(example_plain_env.pressure(0)) - 722.0)
< 1e-8
)
assert abs(example_plain_env.wind_velocity_x(0) - -2.9005178894925043) < 1e-8
assert abs(example_plain_env.temperature(100) - 291.75) < 1e-8

This test contains two fundamental traits which defines it as an integration test:

* (I/O Access) Communication with external dependencies that may not be stable or quick to access.
* Contains the ``all_info()`` method, which is an integration test by convention for RocketPy.

**Observation:** The ``all_info()`` method present in the code is considered to be an integration test.
The motivation behind lies in the fact that it interacts and calls too many methods, being too broad
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved
to be considered an unit test.

Please be aware that Integration tests are not solely classfied when interacting with external dependencies,
but also encompass verifying the interaction between classes or too many methods at once, such as ``all_info()``.

Further clarification: If the test contains traits of unit tests and use dependencies which are stable, such as
.csv or .eng files contained within the project or any other external dependencies which are easy to access
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved
and do not make the test slow, then your test may be classfied as an unit test (solitary or sociable).

Acceptance tests definition
---------------------------

Acceptance tests configure the final phase of the testing lifecycle within RocketPy. These tests are designed to
account for user-scenarios where usually real flights and configurations are setup and launched.
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved

This phase of testing presents the task of letting the developers know if the system still satisfies well enough the
requirements of normal use of the software, including for instance:

* Error free use of the software within the setup of a real launch.
* Assertions regarding the accuracy of simulations. Thresholds are put and should be checked.
* Usually include prior knowledge of real flight data.

In practical terms, acceptance tests come through the form of a notebook where a certain flight is tested.
lucasfourier marked this conversation as resolved.
Show resolved Hide resolved
It is an important feature and also defining feature of the acceptance tests that thresholds are compared
to real flight data allowing for true comparison.


Loading