I'm writing a vector class for 2D geometry applications, and below is a rough draft for a unit test using Cpputest. I'm familiar with unit tests, but this is the first I've done for a purely mathematical class, and I'd really appreciate any insight or feedback no matter how small; even personal preference and knit-picking is welcome.
#include "CppUTest/TestHarness.h"
#include "../Mathos.hpp"
#include <stdio.h>
// Todo: Put padding (2 newlines) between tests.
double const TOLERANCE = 0.000001;
static void VECTORS_EQUAL(double x, double y, mat::Vector2D const &V, double tolerance)
{
DOUBLES_EQUAL(x, V.x, tolerance);
DOUBLES_EQUAL(y, V.y, tolerance);
}
static void VECTORS_EQUAL(mat::Vector2D const &expect,
mat::Vector2D const &actual,
double tolerance)
{
VECTORS_EQUAL(expect.x, expect.y, actual, tolerance);
}
TEST_GROUP(VectorTestGroup)
{
};
TEST(VectorTestGroup, VectorSize)
{
CHECK_EQUAL(16, sizeof(mat::Vector2D));
}
TEST(VectorTestGroup, VectorIsZeroByDefault)
{
mat::Vector2D U;
VECTORS_EQUAL(0, 0, U, TOLERANCE);
}
TEST(VectorTestGroup, VectorCanBeCreatedFromValues)
{
// x is provided, no y
mat::Vector2D U(1);
VECTORS_EQUAL(1, 0, U, TOLERANCE);
mat::Vector2D V(1, 2);
VECTORS_EQUAL(1, 2, V, TOLERANCE);
}
TEST(VectorTestGroup, VectorCanBeCreatedFromVector)
{
mat::Vector2D U(mat::Vector2D(1, 2));
VECTORS_EQUAL(1, 2, U, TOLERANCE);
mat::Vector2D V(U);
VECTORS_EQUAL(1, 2, V, TOLERANCE);
// Calls the mat::Vector(double, double)
// constructor
mat::Vector2D W = mat::Vector2D(4, 3);
VECTORS_EQUAL(4, 3, W, TOLERANCE);
// Calls the copy constructor
mat::Vector2D Z = W;
VECTORS_EQUAL(4, 3, Z, TOLERANCE);
}
TEST(VectorTestGroup, VectorAssignment)
{
mat::Vector2D U(1, 2);
mat::Vector2D V(3, 4);
U = V;
VECTORS_EQUAL(3, 4, U, TOLERANCE);
}
TEST(VectorTestGroup, Vector_addition)
{
mat::Vector2D U(1, 2);
mat::Vector2D V(3, 4);
mat::Vector2D W = U + V;
VECTORS_EQUAL(4, 6, W, TOLERANCE);
}
TEST(VectorTestGroup, Vector_addition_self_assignment)
{
mat::Vector2D A;
const mat::Vector2D B( 1, 2);
const mat::Vector2D C(-3, 4);
const mat::Vector2D D(-5, -6);
const mat::Vector2D E( 7, -8);
A += B;
VECTORS_EQUAL(1, 2, A, TOLERANCE);
A += C;
VECTORS_EQUAL(-2, 6, A, TOLERANCE);
A += D;
VECTORS_EQUAL(-7, 0, A, TOLERANCE);
A += E;
VECTORS_EQUAL(0, -8, A, TOLERANCE);
mat::Vector2D F;
mat::Vector2D G(1, 1);
F = G += A;
VECTORS_EQUAL(1, -7, F, TOLERANCE);
}
TEST(VectorTestGroup, Vector_subtraction)
{
mat::Vector2D U(1, 2);
mat::Vector2D V(3, 4);
mat::Vector2D W = U - V;
VECTORS_EQUAL(-2, -2, W, TOLERANCE);
}
TEST(VectorTestGroup, Vector_subtraction_self_assignment)
{
mat::Vector2D A;
const mat::Vector2D B( 1, 2);
const mat::Vector2D C(-3, 4);
const mat::Vector2D D(-5, -6);
const mat::Vector2D E( 7, -8);
A -= B;
VECTORS_EQUAL(-1, -2, A, TOLERANCE);
A -= C;
VECTORS_EQUAL(2, -6, A, TOLERANCE);
A -= D;
VECTORS_EQUAL(7, 0, A, TOLERANCE);
A -= E;
VECTORS_EQUAL(0, 8, A, TOLERANCE);
mat::Vector2D F;
mat::Vector2D G(1, 1);
F = G -= A;
VECTORS_EQUAL(1, -7, F, TOLERANCE);
}
TEST(VectorTestGroup, Scalar_multiplication)
{
mat::Vector2D U(1, 2);
mat::Vector2D V = 10 * U; // Number comes first
VECTORS_EQUAL(10, 20, V, TOLERANCE);
V = V * 3; // Vector comes first
VECTORS_EQUAL(30, 60, V, TOLERANCE);
}
TEST(VectorTestGroup, Scalar_multiplication_self_assignment)
{
mat::Vector2D A(1, 2);
A *= 2;
VECTORS_EQUAL(2, 4, A, TOLERANCE);
A *= -4.5;
VECTORS_EQUAL(-9, -18, A, TOLERANCE);
mat::Vector2D B = A *= 0.25;
VECTORS_EQUAL(-2.25, -4.5, B, TOLERANCE);
}
TEST(VectorTestGroup, Scalar_division)
{
mat::Vector2D U(1, 2);
mat::Vector2D V = U / 4;
VECTORS_EQUAL(0.25, 0.50, V, TOLERANCE);
}
TEST(VectorTestGroup, Scalar_division_self_assignment)
{
mat::Vector2D A(1, 2);
A /= 2;
VECTORS_EQUAL(0.5, 1, A, TOLERANCE);
A /= -4;
VECTORS_EQUAL(-0.125, -0.25, A, TOLERANCE);
mat::Vector2D B = A /= 0.1;
VECTORS_EQUAL(-1.25, -2.5, B, TOLERANCE);
}
void printVectorTest(const mat::Vector2D& expected, const mat::Vector2D& actual)
{
// Print vector as minimum one digit with 24 digits
// trailing after the decimal point.
printf("\n");
printf("Expected: <%1.24f, %1.24f>\n", expected.x, expected.y);
printf(" Got: <%1.24f, %1.24f>\n", actual.x, actual.y);
}
TEST(VectorTestGroup, Vector_equality)
{
mat::Vector2D const A(1, 2);
mat::Vector2D const B(3, 4);
mat::Vector2D const C(1, 2);
CHECK(A == A);
CHECK(A == C);
CHECK_FALSE(A == B);
}
TEST(VectorTestGroup, Vectors_that_are_about_the_same_are_equal)
{
// This is not exactly 0.3
mat::Vector2D const x(0.3);
// Floating-point rounding errors will build up here
mat::Vector2D const myPointNine = x + x + x;
mat::Vector2D const expectedPointNine(0.9);
printVectorTest(expectedPointNine, myPointNine);
// Even though the result is not exactly 0.9, it's close
// enough to be considered equal to 0.9
CHECK(myPointNine == expectedPointNine);
}
TEST(VectorTestGroup, Subtracting_two_equal_vectors_gives_the_zero_vector)
{
mat::Vector2D const b(3 * mat::Vector2D(0.3) - mat::Vector2D(0.9));
mat::Vector2D const ZERO_VECTOR;
printVectorTest(ZERO_VECTOR, b);
CHECK(b == ZERO_VECTOR);
}
TEST(VectorTestGroup, Very_small_vectors_are_not_equal_to_the_zero_vector)
{
CHECK_FALSE(mat::Vector2D(0.000001) == mat::Vector2D(0));
CHECK_FALSE(mat::Vector2D(0.000000001) == mat::Vector2D(0));
CHECK_FALSE(mat::Vector2D(0.000000000001) == mat::Vector2D(0));
}
TEST(VectorTestGroup, Vector_inequality)
{
mat::Vector2D const A(1, 2);
mat::Vector2D const B(3, 4);
mat::Vector2D const C(1, 2);
CHECK_FALSE(A != A);
CHECK_FALSE(A != C);
CHECK(A != B);
}
TEST_GROUP(VectorOperationsGroup)
{
void setup()
{
mat::Vector2D U;
}
void teardown()
{
}
};
IGNORE_TEST(VectorOperationsGroup, LENGTH)
{
// DOUBLES_EQUAL(5.000000, mat::length( mat::Vector2D( 4, 3) ), TOLERANCE);
// DOUBLES_EQUAL(9.055385, mat::length( mat::Vector2D(-1, 9) ), TOLERANCE);
// DOUBLES_EQUAL(8.246211, mat::length( mat::Vector2D(-8, -2) ), TOLERANCE);
// DOUBLES_EQUAL(7.810250, mat::length( mat::Vector2D( 6, -5) ), TOLERANCE);
CHECK(true);
}
IGNORE_TEST(VectorOperationsGroup, UNIT)
{
// mat::Vector2D const A( 4, 3);
// mat::Vector2D const B(-1, 9);
// mat::Vector2D const C(-8, -2);
// mat::Vector2D const D( 6, -5);
// mat::Vector2D a( mat::unit( A ) );
// mat::Vector2D b( mat::unit( B ) );
// mat::Vector2D c( mat::unit( C ) );
// mat::Vector2D d( mat::unit( D ) );
// DOUBLES_EQUAL(1.000000, mat::length(a), TOLERANCE);
// DOUBLES_EQUAL(1.000000, mat::length(b), TOLERANCE);
// DOUBLES_EQUAL(1.000000, mat::length(c), TOLERANCE);
// DOUBLES_EQUAL(1.000000, mat::length(d), TOLERANCE);
// VECTORS_EQUAL(A, a * mat::length(A), TOLERANCE);
// VECTORS_EQUAL(B, b * mat::length(B), TOLERANCE);
// VECTORS_EQUAL(C, c * mat::length(C), TOLERANCE);
// VECTORS_EQUAL(D, d * mat::length(D), TOLERANCE);
CHECK(true);
}
IGNORE_TEST(VectorOperationsGroup, DOT)
{
// mat::Vector2D const A( 4, 3);
// mat::Vector2D const B(-1, 9);
// mat::Vector2D const C(-8, -2);
// mat::Vector2D const D( 6, -5);
// DOUBLES_EQUAL( 23.00000, mat::dot(A, B), TOLERANCE);
// DOUBLES_EQUAL(-38.00000, mat::dot(A, C), TOLERANCE);
// DOUBLES_EQUAL( 9.000000, mat::dot(A, D), TOLERANCE);
CHECK(true);
}
Vector2D.hpp
#ifndef VECTOR_2D_HPP
#define VECTOR_2D_HPP
#include <ostream>
namespace mat {
class Vector2D {
public:
double x, y;
Vector2D();
Vector2D(double x, double y = 0);
Vector2D(const Vector2D& A);
const Vector2D& operator =(const Vector2D& rhs);
Vector2D operator +(const Vector2D& rhs) const;
Vector2D operator -(const Vector2D& rhs) const;
Vector2D operator /(double k) const;
const Vector2D& operator +=(const Vector2D& rhs);
const Vector2D& operator -=(const Vector2D& rhs);
const Vector2D& operator *=(double k);
const Vector2D& operator /=(double k);
bool operator ==(const Vector2D& rhs) const;
bool operator !=(const Vector2D& rhs) const;
};
Vector2D operator*(double k, const Vector2D& A);
Vector2D operator*(const Vector2D& A, double k);
std::ostream& operator<<(std::ostream& os, const Vector2D& A);
double length(Vector2D const &U);
Vector2D unit(Vector2D const &U);
double dot(Vector2D const &U, Vector2D const &V);
}
#endif
-
1\$\begingroup\$ Nitpick: It's nit-picking (or nitpicking), not knit-picking. ;) \$\endgroup\$DLosc– DLosc2022年04月11日 19:31:22 +00:00Commented Apr 11, 2022 at 19:31
1 Answer 1
Small things first:
- Prefer to include the implementation file first, before the unit-test framework files (just in case we accidentally rely on its transitive includes).
- Include
<osfwd>
rather than<ostream>
where we don't need full definitions. - I don't like (non-member)
*
operator being separated from/
so much - either make both functions non-member, or move the vector-first version of*
within the class. - The
IGNORE_TEST
functions at the end suggest that the code is still unfinished - code should be finished to be ready for review. - We're missing tests of
<<
,length()
,unit()
anddot()
.
Main review:
Please don't use all-caps names for things that are not preprocessor macros. That draws reader attention to all the wrong places.
The whole "tolerance" complication seems unnecessary here. We're working with exact binary numbers here (apart from a division by
0.1
- that can be changed), so we should be able to use ordinary equality (even when we come to testlength()
, we can choose a Pythagorean pair to ensure exact results).Testing the vector's size is an over-test. The size shouldn't matter to user programs, and there's no good reason for it to be a fixed size (after all, it depends directly on the the platform's choice of
double
andchar
types).It's not clear why the arithmetic tests include more than one invocation of the method under test (particularly the assignment versions). A test normally has three phases - initialise, execute, verify - but these ones break that expectation. Prefer more, smaller tests:
TEST(VectorTestGroup, Scalar_multiply_NxV) { VECTORS_EQUAL(mat::Vector2D{10, 20}, 10 * mat::Vector2D{1, 2}); } TEST(VectorTestGroup, Scalar_multiply_VxN) { VECTORS_EQUAL(mat::Vector2D{10, 20}, mat::Vector2D{1, 2} * 10); }
The tests of
==
and!=
imply that those functions have a built-in fuzziness. I would recommend against that, and write a specific "approximately-equals" function instead when fuzzy comparison is required (allowing the user to pass in the relative and/or absolute tolerance to use - a baked-in choice is unlikely to suit all users).One significant aspect of such an unexpected overload is that it violates the contract of
==
: users expect that ifa == b
andb == c
, thena == c
.
-
\$\begingroup\$ The tolerance thing is actually apart of the testing framework itself (see DOUBLES_EQUAL()). Should I just pass in 0 instead? That being said, my class does have an implied fuzziness of its own, and it further complicates things. \$\endgroup\$Mode77– Mode772022年04月02日 11:05:21 +00:00Commented Apr 2, 2022 at 11:05
-
1\$\begingroup\$ I've never used that specific test framework, but yes, if you can't compare
double
values exactly, you should be able to pass zero as the tolerance. The page you linked suggests thatCHECK_EQUAL()
will work, though (sincedouble
has a perfectly fineoperator==()
). \$\endgroup\$Toby Speight– Toby Speight2022年04月02日 13:21:25 +00:00Commented Apr 2, 2022 at 13:21 -
1\$\begingroup\$ I still recommend not having implied fuzziness in your class's
==
, as in my last bullet point. \$\endgroup\$Toby Speight– Toby Speight2022年04月02日 13:22:42 +00:00Commented Apr 2, 2022 at 13:22 -
\$\begingroup\$ So floating point arithmetic works as expected if the numbers are whole? For example,
double(7.0) - double(5.0) == double(2.0)
? The result is exactly 2? \$\endgroup\$Mode77– Mode772022年04月02日 13:43:38 +00:00Commented Apr 2, 2022 at 13:43 -
1\$\begingroup\$ Yes, simple arithmetic with whole numbers (or exact binary fractions such as 0.5, 0.125, 0.375 - assuming your platform uses binary floating-point) always gives exact results, as long as there are enough mantissa bits in the floating point representation. What doesn't work well is division (except by powers of two) or infinite fractions (such as
0.1
on a binary platform). \$\endgroup\$Toby Speight– Toby Speight2022年04月02日 17:00:16 +00:00Commented Apr 2, 2022 at 17:00