How To Test A CGNS-based Mesh Reading Library Using gtest

In this article, we extend our knowledge on how to integrate gtest into our mesh reading library using the CGNS data format for structured and unstructured grids that we developed in a previous series. We will rewrite the unit tests that we have already written using our own primitive testing framework and highlight how gtest can help us to write more succinct test code. We introduce the concept of test fixtures that will help us reuse code and data for different tests.

By the end of this article, you will have all the knowledge you need to apply gtest to your own project and will have seen a realistic application of gtest to test a real CFD project. With the introduction of test fixtures, you will be able to reduce the test code that needs to be written. This will not only result in reduced test code but also provide clarity on how to use your library in external projects. It forms a vital part of the documentation process which often gets ignored, and we will circle back to this point at the end of this article.

Download Resources

All developed code and resources in this article are available for download. If you are encountering issues running any of the scripts, please refer to the instructions for running scripts downloaded from this website.

In this series

In this article

Recap of our CGNS-based mesh reading library

In a previous series, I showed you how to write an entire mesh reading library using the CGNS format from scratch. Our library was able to handle structured and unstructured grids, and the final article introduced some tests to ensure that the grids were read correctly. If you have followed that series and the current one on software testing, then you will have realised that a lot of what we have discussed about software testing was applied in the mesh reading tests.

For example, all tests were testing individual units (functions) of the class, so that we could confidently state that each class was working correctly. Furthermore, each test loosely followed the AAA (Arrange, Act, Assert) pattern. However, you may also remember that our tests got very long, due to an excessive amount of assert statements.

On top of that, we were testing two different versions of the CGNS format; one where the boundaries were written under a family node, and one where they were written under the boundary node for each zone. This resulted in some code duplication as we had to instantiate the mesh reading and then call the same test functions twice. In other words, we had no good way to organise our test data without having to write some code ourselves.

Thus, in this section, I want to show you two things; first, I want to extend your knowledge on gtest from our previous article, and testing in general from our opening article on software testing using test fixtures. These allow us to structure our tests and collect data that is shared by several tests into one convenient class. Secondly, I want to apply this new knowledge to our CGNS-based mesh reading class and rewrite all tests that we wrote before using gtest.

This will give you a good idea of how to use test fixtures, while we extend our library to use gtest as our testing framework. Should we start to add new functionality, we can easily add additional tests to our testing suite and know that we have support for more advanced features should we need them.

We will look at test fixtures in the next section in isolation and then see how we can apply that to our mesh reading library when we rewrite our tests for it.

Introducing test fixtures

As alluded to above, test fixtures are just classes that allow us to provide some shared data that can be reused between tests. In this section, we’ll look at an example without test fixtures, discuss where we could benefit from some shared data, and then introduce test fixtures and see how they are applied using gtest.

A motivating example

To show you how test fixtures work, we will write 2 tests, where we will add or subtract the content of two std::vectors. These tests don’t serve any real needs but they demonstrate why we may want to use test fixtures.

In the code shown below, we write a test for testing vector addition (lines 5-19) and vector subtraction (lines 21-35). This is followed by the boilerplate main() function we need to provide for gtest to find all tests and work properly. Have a look at the code and see if anything is bothering you, I’ll catch you below the code to discuss what I mean.

#include <vector>

#include "gtest/gtest.h"

TEST(VectorTest, addition) {
  // Arrange
  std::vector<double> a{1.2, -2.4, 3.6};
  std::vector<double> b{5.9, 0.7, -1.3};
  std::vector<double> expected{7.1, -1.7, 2.3};
  std::vector<double> result(3);

  // Act
  for (int i = 0; i < 3; ++i)
    result[i] = a[i] + b[i];

  // Assert
  for (int i = 0; i < 3; ++i)
    ASSERT_DOUBLE_EQ(result[i], expected[i]);
}

TEST(VectorTest, subtraction) {
  // Arrange
  std::vector<double> a{1.2, -2.4, 3.6};
  std::vector<double> b{5.9, 0.7, -1.3};
  std::vector<double> expected{-4.7, -3.1, 4.9};
  std::vector<double> result(3);

  // Act
  for (int i = 0; i < 3; ++i)
    result[i] = a[i] - b[i];

  // Assert
  for (int i = 0; i < 3; ++i)
    ASSERT_DOUBLE_EQ(result[i], expected[i]);
}

int main(int argc, char **argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

We have two tests, look closely at the lines 7-10 and 23-26. Apart from the expected results, they are identical. Whenever we see duplicated code, we should think DRY, and try to find ways to avoid it. This is where test fixtures come in. They allow us to encapsulate this shared code or data in a class, which can then be used by each test that needs to have access to it. We will explore how to write this class in the next section.

A rewritten version using a test fixture

The code below provides the same test, now using a test fixture class defined on lines 6-16. The name of the class can be anything, but it makes sense to include the word test here somewhere. The class also has to derive from the Test class, which lives in the testing namespace.

Our class has a few protected variables, shown on lines 13-15. These will be accessible by all tests that make use of this fixture (class). We declare the variable result on line 15, but we need to provide it with the correct size, which we do by resizing it in the constructor on line 8. There are two additional virtual methods we inherit from the Test class, which are called SetUp() and TearDown(). These sound very similar to what a constructor and destructor may do, but they have a subtle difference.

The constructor gets called every time we instantiate an object of that class. However, we mentioned that the class (fixture) may be used by several tests that want to use the same data. Our object may get instantiated only once, which is then reused several times by different tests. The SetUp() method is called before each test makes use of this class, while the TearDown() method is called directly after each test finishes.

In our case, we resize the std::vector on line 8 in the constructor, as for all tests we are using the same-sized std::vector, but we may want to make sure that the result variable is re-initialised to zero for all its entries before each test is executed. In this case, it won’t make a difference, but you may have cases where you want to make sure that variables are set to a specific value, zeroing an array is a common use case.

#include <vector>
#include <algorithm>

#include "gtest/gtest.h"

class VectorTest : public testing::Test {
protected:
  VectorTest() { result.resize(3); }
  ~VectorTest() override { }
  void SetUp() override { std::fill(result.begin(), result.end(), 0.0); }
  void TearDown() override { }
protected:
  std::vector<double> a{1.2, -2.4, 3.6};
  std::vector<double> b{5.9, 0.7, -1.3};
  std::vector<double> result;
};

TEST_F(VectorTest, addition) {
  // Arrange
  std::vector<double> expected{7.1, -1.7, 2.3};

  // Act
  for (int i = 0; i < 3; ++i)
    result[i] = a[i] + b[i];

  // Assert
  for (int i = 0; i < 3; ++i)
    ASSERT_DOUBLE_EQ(result[i], expected[i]);
}

TEST_F(VectorTest, subtraction) {
  // Arrange
  std::vector<double> expected{-4.7, -3.1, 4.9};

  // Act
  for (int i = 0; i < 3; ++i)
    result[i] = a[i] - b[i];

  // Assert
  for (int i = 0; i < 3; ++i)
    ASSERT_DOUBLE_EQ(result[i], expected[i]);
}

int main(int argc, char **argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

What about the tests? Well, instead of calling them TEST(Group, Name), they now become TEST_F(Fixture, Name). Notice the additional _F after TEST. By Fixture, I mean the name of the class. By explicitly writing the name of the class here, we gain access to its protected variables, and before each test is executed, we ensure that the SetUp() and TearDown() method are called.

A common side effect of fixtures is that the Arrange section may be left empty. In the tests we have provided above, we set up the expected results, but very often we can set up all expected results within the fixture, so this section may remain empty.

This is the basic idea behind a test fixture, provide some common data, as well as ensure that the data is in the correct state before each test that depends on it is executed. A good summary is also provided in the gtest documentation. In the next section, we will provide test fixtures for both the structured and unstructured classes that hold the expected data for the grids we are reading.

Rewriting the CGNS mesh reading library tests

In this section, we are going to have a look then at how to rewrite the mesh reading library tests we wrote previously. There is a bit of housekeeping to do before we get into rewriting these tests, which we do first. In particular, we are looking at the modified project structure and build scripts. Afterwards, we create the new test fixtures and unit tests.

As always you can download the full project code from the link provided at the beginning of this article.

Project structure

Much of the project structure was already discussed in detail when we talked about how to set up the mesh reading library and its project structure, so we won’t go into details here again. I am assuming that you are familiar with the mesh reading library, at least from a high-level perspective, and we focus on the differences here.

root
├── mesh
│   ├── structured2D.cgns
│   ├── structured2DNoFamily.cgns
│   ├── unstructured2D.cgns
│   └── unstructured2DNoFamily.cgns
├── meshReaderLib
│   ├── include
│   │   ├── readMeshBase.hpp
│   │   ├── readStructuredMesh.hpp
│   │   ├── readUnstructuredMesh.hpp
│   │   └── types.hpp
│   ├── src
│   │   ├── readMeshBase.cpp
│   │   ├── readStructuredMesh.cpp
│   │   └── readUnstructuredMesh.cpp
│   └── meshReader.hpp
├── tests
│   ├── unit
│   │   ├── testStructuredGrid.cpp
│   │   └── testUnstructuredGrid.cpp
│   └── mainTest.cpp
├── buildAndRun.ps1
├── buildAndRun.sh
├── buildDynamicAndRun.ps1
├── buildDynamicAndRun.sh
├── buildStaticAndRun.ps1
└── buildStaticAndRun.sh

The project structure remained the same, and we have added the tests directory. Within this folder, we have the unit directory, which houses the unit tests for the structured and unstructured mesh reading classes. There is an additional file called mainTest.cpp which houses the boilerplate main() function required by gtest to collect all tests for the test runner to execute.

Build scripts

The meh reading library provided a few build scripts so in this section, we will update all of them. We discussed these in detail before, where we first introduced a simplified build script to build all files into a single executable, which we then later extended to include build scripts for static and dynamic library compilation. We used these libraries to link them against our test functions.

Windows

On Windows, we have to deal again with Microsoft’s problematic compilation into dynamic libraries, but we captured all of that in our build scripts already. In this section, we simply extend our build scripts to include the test functions as well, which we need to link against the gtest library.

Building everything into a single executable

This was the original build script we looked at. All code was compiled into a single executable, without the inclusion of any static or dynamic library. Lines 11-13 are added, which compile the files within the tests/unit/ and tests/ directory. The compiled object files are added on line 16 to the executable called cgnsTest.exe, which is located in the build folder. Notice that we also included the gtest.lib library here during the linking on line 16. The rest of the build script remains unchanged.

# clean up before building
Remove-Item .\build -Force -Recurse
New-Item -Name "build" -ItemType "directory"
Copy-Item "C:\libraries\bin\hdf5.dll" -Destination .\build
Copy-Item "C:\libraries\bin\zlib.dll" -Destination .\build

# compile source files into object files
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\meshReaderLib\src\readMeshBase.cpp /Fo".\build\readMeshBase.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\meshReaderLib\src\readStructuredMesh.cpp /Fo".\build\readStructuredMesh.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\meshReaderLib\src\readUnstructuredMesh.cpp /Fo".\build\readUnstructuredMesh.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\tests\unit\testStructuredGrid.cpp /Fo".\build\testStructuredGrid.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\tests\unit\testUnstructuredGrid.cpp /Fo".\build\testUnstructuredGrid.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\tests\mainTest.cpp /Fo".\build\mainTest.obj"

# link object files against CGNS library and its dependencies
cl.exe /nologo /EHsc /std:c++20 .\build\mainTest.obj .\build\testStructuredGrid.obj .\build\testUnstructuredGrid.obj .\build\readMeshBase.obj .\build\readStructuredMesh.obj .\build\readUnstructuredMesh.obj /Fe".\build\cgnsTest.exe" /link /MACHINE:x64 /LIBPATH:"C:\libraries\lib" cgns.lib hdf5.lib gtest.lib msvcrt.lib libcmt.lib
.\build\cgnsTest.exe
Building a separate static library and test executable

If you understood the previous build script, then this will feel very similar. The instructions to generate the static library are unaltered in this case, i.e. lines 8-13 are the same as the original build script. I have added lines 16-18 to compile our test code, which is then used on line 21 to create a test executable (and, to apparently complete our Fibonacci sequence, who would have thought we would find a golden ratio in our build script!).

The test executable created on line 21 links against the static library we created for the mesh reading library on line 13. It also links against gtest again, so we have the testing framework available in our executable.

# clean up before building
Remove-Item .\build -Force -Recurse
New-Item -Name "build" -ItemType "directory"
Copy-Item "C:\libraries\bin\hdf5.dll" -Destination .\build
Copy-Item "C:\libraries\bin\zlib.dll" -Destination .\build

# compile source files into object files for CGNS library
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\meshReaderLib\src\readMeshBase.cpp /Fo".\build\readMeshBase.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\meshReaderLib\src\readStructuredMesh.cpp /Fo".\build\readStructuredMesh.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\meshReaderLib\src\readUnstructuredMesh.cpp /Fo".\build\readUnstructuredMesh.obj"

# link static library
lib.exe /nologo /OUT:build\meshReader.lib build\readMeshBase.obj build\readStructuredMesh.obj build\readUnstructuredMesh.obj

# compile tests
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\tests\mainTest.cpp /Fo".\build\mainTest.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\tests\unit\testStructuredGrid.cpp /Fo".\build\testStructuredGrid.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILELIB .\tests\unit\testUnstructuredGrid.cpp /Fo".\build\testUnstructuredGrid.obj"

# link library into main executable
cl.exe /nologo /EHsc /std:c++20 .\build\mainTest.obj .\build\testStructuredGrid.obj .\build\testUnstructuredGrid.obj /Fe".\build\cgnsTest.exe" /link /MACHINE:x64 /LIBPATH:"C:\libraries\lib" /LIBPATH:.\build meshReader.lib gtest.lib cgns.lib hdf5.lib msvcrt.lib libcmt.lib

# clean up
Remove-Item build\*.obj

# test executable
.\build\cgnsTest.exe
Building a separate dynamic library and test executable

The build script for the dynamic library is very similar to the build script created for the static library above. In fact, the main difference is the creation of the dynamic library, which is the same as in the original article where we looked at creating this. The additional lines are again on lines 16-18 to compile our unit tests.

Line 21 links our test executable again against the dynamic mesh reader library created on line 13, as well as gtest. We have again the confusing notion on Windows that, despite building a dynamic library on line 13 (indicated by the /DLL flag), we still build a pseudo static library, i.e. meshReader.lib, along with the dynamic library meshReader.dll. The static library is simply a wrapper which delegates all function calls to the meshReader.dll library. I have given up questioning why.

# clean up before building
Remove-Item .\build -Force -Recurse
New-Item -Name "build" -ItemType "directory"
Copy-Item "C:\libraries\bin\hdf5.dll" -Destination .\build
Copy-Item "C:\libraries\bin\zlib.dll" -Destination .\build

# compile source files into object files for CGNS library
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILEDLL .\meshReaderLib\src\readMeshBase.cpp /Fo".\build\readMeshBase.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILEDLL .\meshReaderLib\src\readStructuredMesh.cpp /Fo".\build\readStructuredMesh.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILEDLL .\meshReaderLib\src\readUnstructuredMesh.cpp /Fo".\build\readUnstructuredMesh.obj"

# link dynamic library
link.exe /nologo /DLL /OUT:build\meshReader.dll build\readMeshBase.obj build\readStructuredMesh.obj build\readUnstructuredMesh.obj /LIBPATH:"C:\libraries\lib" cgns.lib hdf5.lib msvcrt.lib libcmt.lib

# compile tests
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILEDLL .\tests\mainTest.cpp /Fo".\build\mainTest.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILEDLL .\tests\unit\testStructuredGrid.cpp /Fo".\build\testStructuredGrid.obj"
cl.exe /nologo /EHsc /std:c++20 /I. /I "C:\libraries\include" /c /O2 /DCOMPILEDLL .\tests\unit\testUnstructuredGrid.cpp /Fo".\build\testUnstructuredGrid.obj"

# link library into main executable
cl.exe /nologo /EHsc /std:c++20 .\build\mainTest.obj .\build\testStructuredGrid.obj .\build\testUnstructuredGrid.obj /Fe".\build\cgnsTest.exe" /link /MACHINE:x64 /LIBPATH:"C:\libraries\lib" /LIBPATH:.\build meshReader.lib gtest.lib cgns.lib hdf5.lib msvcrt.lib libcmt.lib

# clean up
Remove-Item build\*.obj

# test executable
.\build\cgnsTest.exe

UNIX

Similar to Windows, we will look at the additional lines of code we have to provide to compile our tests, using gtest now on UNIX with bash. For a more detailed description of the original build scripts, you can check out the discussion for the simplified build script and the static and dynamic build scripts.

Building everything into a single executable

To compile everything into a single executable, without separating the library and test code, we simply have to add the compilation instruction for the test files, which is done on lines 10-12. We include the generated object files on line 15, where we also link against the gtest library using -lgtest.

#!/bin/bash

rm -rf build
mkdir -p build

# compile source files into object files
g++ -c -std=c++20 -I. -I ~/libs/include ./meshReaderLib/src/readMeshBase.cpp -o ./build/readMeshBase.o
g++ -c -std=c++20 -I. -I ~/libs/include ./meshReaderLib/src/readStructuredMesh.cpp -o ./build/readStructuredMesh.o
g++ -c -std=c++20 -I. -I ~/libs/include ./meshReaderLib/src/readUnstructuredMesh.cpp -o ./build/readUnstructuredMesh.o
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/unit/testStructuredGrid.cpp -o ./build/testStructuredGrid.o
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/unit/testUnstructuredGrid.cpp -o ./build/testUnstructuredGrid.o
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/mainTest.cpp -o ./build/mainTest.o

# link object files against CGNS library and its dependencies
g++ -std=c++20 ./build/mainTest.o ./build/testStructuredGrid.o ./build/testUnstructuredGrid.o ./build/readMeshBase.o ./build/readStructuredMesh.o ./build/readUnstructuredMesh.o -o ./build/cgnsTest -L ~/libs/lib -lcgns -lgtest

# run executable
./build/cgnsTest
Building a separate static library and test executable

For the static library, we follow a similar pattern, although we now separate the CGNS mesh reading library from the test code, which means that the library compilation is unaffected by our changes. This means lines 7-12 remain the same. We add our test code compilation instruction on lines 15-17 and then proceed to link all test code into a single test executable on line 20. This executable is linked against our mesh reading library created on line 12, but also gtest.

#!/bin/bash

rm -rf build
mkdir -p build

# compile source files into object code
g++ -c -std=c++20 -I. -I ~/libs/include ./meshReaderLib/src/readMeshBase.cpp -o ./build/readMeshBase.o
g++ -c -std=c++20 -I. -I ~/libs/include ./meshReaderLib/src/readStructuredMesh.cpp -o ./build/readStructuredMesh.o
g++ -c -std=c++20 -I. -I ~/libs/include ./meshReaderLib/src/readUnstructuredmesh.cpp -o ./build/readUnstructuredmesh.o

# link object files into static library
ar rcs build/libMeshReader.a build/readMeshBase.o build/readStructuredMesh.o build/readUnstructuredmesh.o

# compile tests
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/unit/testStructuredGrid.cpp -o ./build/testStructuredGrid.o
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/unit/testUnstructuredGrid.cpp -o ./build/testUnstructuredGrid.o
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/mainTest.cpp -o ./build/mainTest.o

# compile main function and link library into executable
g++ -I. ./build/mainTest.o ./build/testStructuredGrid.o ./build/testUnstructuredGrid.o -o ./build/cgnsTest -Lbuild -L ~/libs/lib -lMeshReader -lcgns -lgtest

# remove object files
rm build/*.o

# run
./build/cgnsTest
Building a separate dynamic library and test executable

Compiling a dynamic version of our mesh reading library remains the same as the original build script, and also compiling the test code is almost the same as compiling the tests for the static library. The only difference here is that we are linking against a dynamic library, thus we have to provide the runtime path (rpath) as a linker argument so the executable will know where to look for the dynamic mesh reader library. This is provided on line 20. The remaining arguments remain the same as provided for the static library.

#!/bin/bash

rm -rf build
mkdir -p build

# compile source files into object code
g++ -c -std=c++20 -fPIC -I. -I ~/libs/include ./meshReaderLib/src/readMeshBase.cpp -o ./build/readMeshBase.o
g++ -c -std=c++20 -fPIC -I. -I ~/libs/include ./meshReaderLib/src/readStructuredMesh.cpp -o ./build/readStructuredMesh.o
g++ -c -std=c++20 -fPIC -I. -I ~/libs/include ./meshReaderLib/src/readUnstructuredmesh.cpp -o ./build/readUnstructuredmesh.o

# link object files into static library
g++ -shared -O3 -Wall -Wextra -I. -o build/libMeshReader.so build/readMeshBase.o build/readStructuredMesh.o build/readUnstructuredmesh.o

# compile tests
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/unit/testStructuredGrid.cpp -o ./build/testStructuredGrid.o
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/unit/testUnstructuredGrid.cpp -o ./build/testUnstructuredGrid.o
g++ -c -std=c++20 -I. -I ~/libs/include ./tests/mainTest.cpp -o ./build/mainTest.o

# compile main function and link library into executable
g++ -Wall -Wextra -I. ./build/mainTest.o ./build/testStructuredGrid.o ./build/testUnstructuredGrid.o -o build/cgnsTest -Lbuild -L ~/libs/lib -Wl,-rpath=build -lMeshReader -lcgns -lgtest

# remove object files
rm build/*.o

# run
./build/cgnsTest

Test the mesh reading library code

With the project structure and build scripts defined, let’s have a look at some actual code. We saw that we need to provide the main() file again, which is located within the tests directory. This is a boilerplate main function that we can reuse in pretty much all of our projects where we want to use gtest. The advantage of providing your own main() function is that you are able to perform some additional setup, should you need it, before you start running all your tests. Most of the time, we probably don’t need to do that, so the main() function provided below is sufficient.

#include "gtest/gtest.h"

int main(int argc, char **argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

Structured grid

We will first look at the test code for the structured grid. We start by looking at the test fixture, which will require some explanation, and then we will move on to look at how we can use that fixture in our coordinate, interface, and boundary condition reading tests, which we will rewrite using gtest.

Test fixture

If you have a look at the file tests/unit/testStructuredGrid.cpp, the first part of the file will consist of the header include statements, followed by the ReadStructuredTest class, that inherits from gtest’s ::testing::Test class, thus making it a fixture. We specify that we want to store a number of structured grids in a std::vector, as seen on line 25, and then within the constructor, we start reading both the structured grid with and without family boundary conditions. Just as a reminder, we looked at what a family node is during the mesh reader library creation.

These two grids, for which we create a mesh reading object on lines 14-15, are first set up by reading the mesh (lines 17-18), and then put inside the std::vector on lines 20-21. Once we look at the actual test code, we will see why putting these different grids in a vector is beneficial, as it allows us to write the test code once, and then loop with each grid over the same test code.

Within the protected area of the class from line 24, we define a number of variables that we intend to use in our tests, and we will discuss them below the class.

#include <filesystem>
#include <vector>

#include "gtest/gtest.h"

#include "meshReaderLib/meshReader.hpp"

class ReadStructuredMeshTest : public ::testing::Test {
public:
  ReadStructuredMeshTest() {
    std::filesystem::path meshFileWithFamily("mesh/structured2D.cgns");
    std::filesystem::path meshFileWithNoFamily("mesh/structured2DNoFamily.cgns");

    ReadStructuredMesh meshWithFamily(meshFileWithFamily);
    ReadStructuredMesh meshWithNoFamily(meshFileWithNoFamily);

    meshWithFamily.readMesh();
    meshWithNoFamily.readMesh();

    structuredMesh.push_back(meshWithFamily);
    structuredMesh.push_back(meshWithNoFamily);
  }

protected:
  std::vector<ReadStructuredMesh> structuredMesh;
  std::vector<std::vector<std::vector<double>>> testCoordinateX {
    {
      {0.00, 0.00, 0.00, 0.00, 0.00},
      {0.25, 0.25, 0.25, 0.25, 0.25},
      {0.50, 0.50, 0.50, 0.50, 0.50},
      {0.75, 0.75, 0.75, 0.75, 0.75},
      {1.00, 1.00, 1.00, 1.00, 1.00}
    },
    {
      {1.00, 1.00, 1.00, 1.00, 1.00},
      {1.25, 1.25, 1.25, 1.25, 1.25},
      {1.50, 1.50, 1.50, 1.50, 1.50},
      {1.75, 1.75, 1.75, 1.75, 1.75},
      {2.00, 2.00, 2.00, 2.00, 2.00}
    }
  };
  std::vector<std::vector<std::vector<double>>> testCoordinateY {
    {
      {0.00, 0.25, 0.50, 0.75, 1.00},
      {0.00, 0.25, 0.50, 0.75, 1.00},
      {0.00, 0.25, 0.50, 0.75, 1.00},
      {0.00, 0.25, 0.50, 0.75, 1.00},
      {0.00, 0.25, 0.50, 0.75, 1.00}
    },
    {
      {0.00, 0.25, 0.50, 0.75, 1.00},
      {0.00, 0.25, 0.50, 0.75, 1.00},
      {0.00, 0.25, 0.50, 0.75, 1.00},
      {0.00, 0.25, 0.50, 0.75, 1.00},
      {0.00, 0.25, 0.50, 0.75, 1.00}
    }
  };
  std::vector<std::vector<std::vector<unsigned>>> testInterfaces {
    {
      {4, 4, 4, 4, 4},
      {0, 1, 2, 3, 4}
    },
    {
      {0, 0, 0, 0, 0},
      {0, 1, 2, 3, 4}
    }
  };
  std::vector<std::vector<std::vector<std::vector<unsigned>>>> testBoundaryConditions {
    {
      {
        {0, 1, 2, 3, 4},
        {0, 0, 0, 0, 0}
      },
      {
        {0, 1, 2, 3, 4},
        {4, 4, 4, 4, 4}
      },
      {
        {0, 0, 0, 0, 0},
        {0, 1, 2, 3, 4}
      }
    },
    {
      {
        {0, 1, 2, 3, 4},
        {0, 0, 0, 0, 0}
      },
      {
        {4, 4, 4, 4, 4},
        {0, 1, 2, 3, 4}
      },
      {
        {0, 1, 2, 3, 4},
        {4, 4, 4, 4, 4}
      }
    }
  };
};

The remaining variables are all multi-dimensional std::vectors of varying datatype and I wanted to look at the syntax first, as some may be unfamiliar with the brace initialisation syntax. If I want to specify the content of a std::vector at the time I define it, I have to use a brace initialisation as shown above. To make things simpler, consider the following examples:

#include <vector>

int main() {

  // empty vector of size 0
  std::vector<int> v1;

  // empty vector of size 2
  std::vector<int> v2(2);

  // vector of size 2, each element initialised to 1
  std::vector<int> v3(2, 1);

  // vector of size 2, with elements 2 and 1
  std::vector<int> v4{2, 1};

  // 2D vector, initialised to a identity matrix, i.e.
  //
  //    | 1 0 |
  //    | 0 1 |
  //
  // with
  // - v5[0][0] = 1
  // - v5[0][1] = 0
  // - v5[1][0] = 0
  // - v5[1][1] = 1
  std::vector<std::vector<int>> v5{{1, 0},{0, 1}};  

  return 0;
}

We see on line 15 that we can use the curly braces {} to initialise the elements within a 1D std::vector. We can extend this to 2D and higher dimensional std::vectors, as we see on line 27. Here, we define the 2D identity matrix, and we can use nested brace initialisation to initialise each dimension. As a simple rule, for each dimension in the std::vector (2 in the case on line 27, 1 in the case on line 15), you have to use the same number of opening and closing curly braces. This helps when dealing with higher-dimensional std::vectors, as we have to do in our test fixture.

Let’s have a look at one of them. Consider the x-coordinate std::vector, this is given within the test fixture on lines 26-41. I’ll repeat it below so it is easy to follow the discussion:

std::vector<std::vector<std::vector<double>>> testCoordinateX {
  {
    {0.00, 0.00, 0.00, 0.00, 0.00},
    {0.25, 0.25, 0.25, 0.25, 0.25},
    {0.50, 0.50, 0.50, 0.50, 0.50},
    {0.75, 0.75, 0.75, 0.75, 0.75},
    {1.00, 1.00, 1.00, 1.00, 1.00}
  },
  {
    {1.00, 1.00, 1.00, 1.00, 1.00},
    {1.25, 1.25, 1.25, 1.25, 1.25},
    {1.50, 1.50, 1.50, 1.50, 1.50},
    {1.75, 1.75, 1.75, 1.75, 1.75},
    {2.00, 2.00, 2.00, 2.00, 2.00}
  }
};

We see that it is a 3D std::vector, as we have a dimension for the number of zones and the number of vertices in the x and y directions. If we wanted to index the above-shown testCoordinateX array, we would have to do so with testCoordinateX[zoneId][i][j]. If you remember what our structured grid looked like, then you will remember that we have 2 zones. Thus, we have two zone entries, which are shown on lines 2-8, and 9-15, respectively.

Within each zone, we have 5 grid points in the x-direction (for both zones), shown on lines 3-7, and 10-14, respectively, and each grid point in x has an additional 5 grid points in the y-direction, which can be seen by the 5 values within each curly braces on lines 3-7 and 10-14. So, if we think back to our structure grid, on the first zone, when i=j=0, we know that we are at the bottom left of the grid and the x and y coordinates are both zero. testCoordinateX[0][0][0] = 0.00, as we can see on line 3, first entry.

With the remaining structures in the test fixture, we specify the data similarly. For example, on lines 58-67, we specify the indices that are connected between two different zones at an interface. It is a 3D std::vector that stores for each interface (first index) the i and j index at the interface (second and third index).

For the boundary conditions, we similarly define the boundary indices on lines 68-97, where we store for each zone (first index), and for each boundary condition on that zone (second index) the i and j indices that define this boundary patch (third and fourth index). This notation may take some time to get used to, especially for higher-dimensional containers. If you want to look further into this, there is a nice write-up over at digital ocean (they usually have quite good guides on various aspects of programming, mostly for web development though).

Coordinate reading test

So, we defined our ReadStructuredMeshTest class, and we also stored the corresponding x and y coordinate that we would expect of our mesh. With that, we are in a position to now rewrite our mesh reading coordinate test. Before we go ahead, just refresh your memory of how we did the coordinate reading test before and then come back here. You will see, that our original test was 129 lines long. Our new test using fixtures is now 21 lines long, as shown below.

We can see that this test is using a fixture by the TEST_F macro, and the first argument is again the name of the fixture (class) itself, i.e. ReadStructuredMeshTest. On line 2, we loop over all structured meshes we have defined. Remember, this is the std::vector holding two grids, one where the boundary condition is stored under a family node, and one where it is stored directly within the zone. This should not have any influence over the coordinate reading, but we test it anyway to make sure.

Then, within the loop between lines 2-20, we go through our usual Arrange, Act, and Assert sections. The Arrange section is now empty since we used a fixture and were able to set everything up in its constructor. We read the coordinates on line 6, ensure that we have 2 zones in total on line 9, and then step through each zone and i, j coordinate pair to ensure that the coordinate we have read from the mesh is the same as the coordinate we expect. This is shown on lines 15-16.

Remember how we had to use this trick of subtracting the difference between the read and expected coordinate and then comparing if the absolute value is close to zero? This is what we did when we were not using a test framework, now, of course, we can use gtest’s ASSERT_DOUBLE_EQ function, which allows us to test two numbers to within plus or minus 2 ULPs (don’t worry if you missed the discussion on ULPs, let’s just say this is an extremely tight tolerance and within the precision of what a double can represent as a number).

TEST_F(ReadStructuredMeshTest, readCoordinates) {
  for (const auto& mesh : structuredMesh) {
    // Arrange

    // Act
    auto coordinates = mesh.getCoordinates();

    // Assert
    ASSERT_EQ(coordinates.size(), 2);
    for (int zone = 0; zone < coordinates.size(); ++zone) {
      ASSERT_EQ(coordinates[zone].size(), 5);
      for (int i = 0; i < coordinates[zone].size(); ++i) {
        ASSERT_EQ(coordinates[zone][i].size(), 5);
        for (int j = 0; j < coordinates[zone][i].size(); ++j) {
          ASSERT_DOUBLE_EQ(coordinates[zone][i][j][COORDINATE::X], testCoordinateX[zone][i][j]);
          ASSERT_DOUBLE_EQ(coordinates[zone][i][j][COORDINATE::Y], testCoordinateY[zone][i][j]);
        }
      }
    }
  }
}
Interface reading test

The interface test follows a similar pattern, we still make use of the test fixture here, and loop over both grids between lines 2-38 to test the family and non-family node-storing boundary condition implementation. The arrange section remains empty, we read the interface information on line 6 and then descent into a series of assert statements.

We first check that we have a single interface (line 9), and then that for this single interface, the owner and neighbour zone have indices of 0 and 1, respectively (lines 10-11). We expect to have a total of 5 vertices on the interface (lines 12-13), and these assertions are essentially the same as our original test. If you want to read up again on why we expect these values, have a look at the original interface reading test.

TEST_F(ReadStructuredMeshTest, readInterfaces) {
  for (const auto& mesh : structuredMesh) {
    // Arrange

    // Act
    auto meshInterfaces = mesh.getInterfaceConnectivity();

    // Assert
    ASSERT_EQ(meshInterfaces.size(), 1);
    ASSERT_EQ(meshInterfaces[0].zones[0], 0);
    ASSERT_EQ(meshInterfaces[0].zones[1], 1);
    ASSERT_EQ(meshInterfaces[0].ownerIndex.size(), 5);
    ASSERT_EQ(meshInterfaces[0].neighbourIndex.size(), 5);

    for (int interface = 0; interface < meshInterfaces.size(); ++interface) {
      for (int index = 0; index < meshInterfaces[interface].ownerIndex.size(); ++index) {
        auto ownerSize = meshInterfaces[interface].ownerIndex.size();
        auto neighbourSize = meshInterfaces[interface].neighbourIndex.size();
        ASSERT_EQ(ownerSize, neighbourSize);

        auto receivedOwnerX = meshInterfaces[interface].ownerIndex[index][COORDINATE::X];
        auto expectedOwnerX = testInterfaces[0][COORDINATE::X][index];
        ASSERT_EQ(receivedOwnerX, expectedOwnerX);

        auto receivedOwnerY = meshInterfaces[interface].ownerIndex[index][COORDINATE::Y];
        auto expectedOwnerY = testInterfaces[0][COORDINATE::Y][index];
        ASSERT_EQ(receivedOwnerY, expectedOwnerY);

        auto receivedNeighbourX = meshInterfaces[interface].neighbourIndex[index][COORDINATE::X];
        auto expectedNeighbourX = testInterfaces[1][COORDINATE::X][index];
        ASSERT_EQ(receivedNeighbourX, expectedNeighbourX);

        auto receivedNeighbourY = meshInterfaces[interface].neighbourIndex[index][COORDINATE::Y];
        auto expectedNeighbourY = testInterfaces[1][COORDINATE::Y][index];
        ASSERT_EQ(receivedNeighbourY, expectedNeighbourY);
      }
    }
  }
}

From lines 15-37, we loop over all interfaces (just one in this case), and then all indices within that interface (lines 16-36), and first check that the number of vertices in the owner and neighbour zone are the same (lines 17-19). Then, we simply retrieve the expected and read indices for the owner in the x (lines 21-23) and y (lines 25-27) direction, and repeat this for the neighbour indices on lines 29-35) for both the x and y direction.

Boundary condition reading test

If you have digested the interface reading test, then the boundary condition reading test will feel very similar. Again, it may be helpful to have a look at the original test first, just to remind you what we are expecting. If we do that, we can see that since we specified the vertices we expect to be on the boundaries within the fixture, we are able to reduce the size of the test again, this time by about a factor of 3. We perform the same initial assertions on lines 9-19, where we ensure that the read boundary conditions have the correct size and boundary type.

We then loop over all zones on line 21, and over each boundary for that zone on line 22, and then go through the same asserts we did in the original test. We check that each boundary has exactly 5 vertices per boundary on line 23, and then check that the vertices correspond to the vertices we expect for each boundary patch on lines 25-31, for both the x and y direction.

TEST_F(ReadStructuredMeshTest, readBoundaryConditions) {
  for (const auto& mesh : structuredMesh) {
    // Arrange

    // Act
    auto bc = mesh.getBoundaryConditions();

    // Assert
    ASSERT_EQ(bc.size(), 2);
    ASSERT_EQ(bc[0].size(), 3);
    ASSERT_EQ(bc[1].size(), 3);

    ASSERT_EQ(bc[0][0].boundaryType, BC::WALL);
    ASSERT_EQ(bc[0][1].boundaryType, BC::SYMMETRY);
    ASSERT_EQ(bc[0][2].boundaryType, BC::INLET);

    ASSERT_EQ(bc[1][0].boundaryType, BC::WALL);
    ASSERT_EQ(bc[1][1].boundaryType, BC::OUTLET);
    ASSERT_EQ(bc[1][2].boundaryType, BC::SYMMETRY);

    for (int zone = 0; zone < bc.size(); ++zone) {
      for (int boundary = 0; boundary < bc[zone].size(); ++boundary) {
        ASSERT_EQ(bc[zone][boundary].indices.size(), 5);
        for (int vertex = 0; vertex < bc[zone][boundary].indices.size(); ++vertex) {
          auto receivedValueX = bc[zone][boundary].indices[vertex][COORDINATE::X];
          auto expectedValueX = testBoundaryConditions[zone][boundary][COORDINATE::X][vertex];
          ASSERT_EQ(receivedValueX, expectedValueX);

          auto receivedValueY = bc[zone][boundary].indices[vertex][COORDINATE::Y];
          auto expectedValueY = testBoundaryConditions[zone][boundary][COORDINATE::Y][vertex];
          ASSERT_EQ(receivedValueY, expectedValueY);
        }
      }
    }
  }
}

This completes the discussion on the structured grids. I have been, on purpose, a bit quicker than usual going through the discussion, as we have looked at the tests themselves already in detail when we were writing the CGNS-based mesh reading library. My goal here is not to repeat the same information but to concentrate on the new structure of the tests that we gained by using gtest instead of our own testing framework.

Unstructured

Having gone through the motion of defining the fixture and rewriting our unit tests for the structured mesh, the unstructured mesh should feel very similar. We’ll go through the remaining code quickly. As we did for the structured grid, make sure that the unstructured mesh reading tests are fresh in your memory, so that the following discussion will make sense. At a minimum, look at the link above to remind yourself what the unstructured grid looked like so that the following tests make more sense.

Test fixture

The test fixture is pretty much the same compared to the structured grid, with the only difference here being that we read the unstructured grids in the constructor, not the structured grids and that our variables defined from line 26 onwards now follow the unstructured mesh data structures.

For example, we have lost one dimension for our x and y coordinates that we expect, i.e. lines 26-29 and 30-33, respectively. This is because for unstructured grids we do not loop over i, j index pairs, but rather vertex indices instead. Thus, we have only 2 dimensions here, where the first index represents the zones (we have 2 again) and the second index is either the x or y coordinate, depending on which std::vector we look at.

We have to add one additional array, and that is the one for the cells on lines 34-59. We need to specify which vertices combined create a cell, and this means we will have one more test compared to the structured mesh reading case.

Looking at the interface definition on lines 60-89, this looks rather different and is a 5-dimensional array now, so let’s discuss what is stored here. For unstructured grids, interfaces are stored on a per-zone basis, not globally as is the case for structured grids. This, the first index is again looping over all zones, and the second index over all interfaces within that zone. The third index differentiates between the owner and the neighbour of the interface, while the fourth index loops over all vertices within an interface.

The last (fifth) index has 2 entries, where we define the boundary cell that makes up the face on the interface. Since our mesh is 2D, a boundary element will always have one less dimension. In 1D, boundary elements will always be lines/edges/bars and always consist of 2 vertices. These are the vertices we store in the fifth index.

The boundary information on lines 90-119 stores very similar information, where the first index loops again over all zones, and the second over all boundaries. We don’t have to store an owner and neighbour for boundaries like we had for interfaces, thus this dimension does not exist. But the remaining third and fourth dimension is defined akin to the fourth and fifth dimensions of our interface, i.e. the third dimension goes through all elements on the boundary, while the fourth dimension stores the vertices that make up a boundary cell (again, only two vertices for a 1D element).

#include <filesystem>
#include <vector>

#include "gtest/gtest.h"

#include "meshReaderLib/meshReader.hpp"

class ReadUnstructuredMeshTest : public ::testing::Test {
public:
  ReadUnstructuredMeshTest() {
    std::filesystem::path meshFileWithFamily("mesh/unstructured2D.cgns");
    std::filesystem::path meshFileWithNoFamily("mesh/unstructured2DNoFamily.cgns");

    ReadUnstructuredMesh meshWithFamily(meshFileWithFamily);
    ReadUnstructuredMesh meshWithNoFamily(meshFileWithNoFamily);

    meshWithFamily.readMesh();
    meshWithNoFamily.readMesh();

    unstructuredMesh.push_back(meshWithFamily);
    unstructuredMesh.push_back(meshWithNoFamily);
  }

protected:
  std::vector<ReadUnstructuredMesh> unstructuredMesh;
  std::vector<std::vector<double>> coordinateX {
    {0, 0.5, 1, 1, 1, 0.5, 0, 0, 0.75, 0.5, 0.75, 0.25, 0.25},
    {1, 1.5, 2, 2, 2, 1.5, 1, 1, 1.5}
  };
  std::vector<std::vector<double>> coordinateY {
    {0, 0, 0, 0.5, 1, 1, 1, 0.5, 0.75, 0.5, 0.25, 0.25, 0.75},
    {0, 0, 0, 0.5, 1, 1, 1, 0.5, 0.5}
  };
  std::vector<std::vector<std::vector<unsigned>>> testCells {
    {
      {
        {2, 10, 1},
        {4, 8, 3},
        {12, 5, 6},
        {11, 7, 0},
        {2, 3, 10},
        {12, 6, 7},
        {4, 5, 8},
        {11, 0, 1},
        {8, 5, 12, 9},
        {9, 12, 7, 11},
        {9, 11, 1, 10},
        {8, 9, 10, 3}
      }
    },
    {
      {
        {5, 6, 7, 8},
        {8, 7, 0, 1},
        {8, 1, 2, 3},
        {5, 8, 3, 4}
      }
    }
  };
  std::vector<std::vector<std::vector<std::vector<std::vector<unsigned>>>>> testInterfaces {
    {
      {
        {
          {
            {2, 3},
            {3, 4}
          },
          {
            {0, 7},
            {7, 6}
          }
        }
      }
    },
    {
      {
        {
          {
            {2, 3},
            {3, 4}
          },
          {
            {0, 7},
            {7, 6}
          }
        }
      }
    }
  };
  std::vector<std::vector<std::vector<std::vector<unsigned>>>> testBoundaryConditions {
    {
      {
        {6,7},
        {7,0}
      },
      {
        {4,5},
        {5,6}
      },
      {
        {0,1},
        {1,2}
      }
    },
    {
      {
        {2,3},
        {3,4}
      },
      {
        {4,5},
        {5,6}
      },
      {
        {0,1},
        {1,2}
      }
    }
  };
};
Coordinate reading test

For the boundary condition reading, we follow a similar structure to the structured mesh reading test, where we loop over the unstructured grids we created in the constructor of the test fixture. Thus, we have again an empty arrange section. We read the coordinates in the act section and then first assert that the sizes are correct, i.e. we have 2 zones in total, where the first zone has 13 vertices, and the second 9 vertices.

We then simply assert that the coordinates we received are the same as the ones specified in the test fixture on lines 15-16, and by now this type of testing should feel very familiar.

TEST_F(ReadUnstructuredMeshTest, readCoordinates) {
  for (const auto& mesh : unstructuredMesh) {
    // Arrange

    // Act
    auto coordinates = mesh.getCoordinates();

    // Assert
    ASSERT_EQ(coordinates.size(), 2);
    ASSERT_EQ(coordinates[0].size(), 13);
    ASSERT_EQ(coordinates[1].size(), 9);

    for (int zone = 0; zone < coordinates.size(); ++zone) {
      for (int i = 0; i < coordinates[zone].size(); ++i) {
        ASSERT_DOUBLE_EQ(coordinates[zone][i][COORDINATE::X], coordinateX[zone][i]);
        ASSERT_DOUBLE_EQ(coordinates[zone][i][COORDINATE::Y], coordinateY[zone][i]);
      }
    }
  }
}
Internal cell test

The cell testing is very similar in structure to the coordinate reading we just looked at, in the sense that we read the cell information first on line 6 and then perform some initial asserts, ensuring that we have the correct number of zones and cells per zone on lines 9-11.

We then loop over all zones, cells, and vertices within each cell and ensure that the vertices are the same. Actually, this is an example of a brittle test and I was originally thinking of changing it but thought it would make for a good case study. This way we can practice our skills in identifying brittle tests.

Remember, a brittle test may fail even if the underlying implementation is correct (i.e. it will produce a false positive). Brittle tests can be frustrating to resolve as we are now spending time fixing test code while our original implementation is fine and produces the correct behaviour. Have a look at the code below and try to identify the issue, and if you think you got it, read on below the test where we will discuss the issue.

TEST_F(ReadUnstructuredMeshTest, readInternalCells) {
  for (const auto& mesh : unstructuredMesh) {
    // Arrange

    // Act
    auto cells = mesh.getInternalCells();

    // Assert
    ASSERT_EQ(cells.size(), 2);
    ASSERT_EQ(cells[0].size(), 12);
    ASSERT_EQ(cells[1].size(), 4);

    for (int zone = 0; zone < cells.size(); ++zone) {
      for (int cell = 0; cell < cells[zone].size(); ++cell) {
        for (int vertex = 0; vertex < cells[zone][cell].size(); ++vertex) {
          ASSERT_EQ(cells[zone][cell][vertex], testCells[zone][cell][vertex]);
        }
      }
    }
  }
}

Let’s say we have defined a quad element with the vertices {0, 1, 2, 8}. When we read our cells, these may be the vertices we get back. So, in our testCells data structure, we make sure that for the given zone and cell, we have the same vertices, i.e. {0, 1, 2, 8}. But what if this, for some reason, changes? What if the cell is now defined as {1, 2, 8, 0}? It will still create the same cell, but our test now fails, as the first assertion will say that the 0 != 1, when we compare the first cell index of both arrays.

So we spend time to figure out why our code is wrong, only to figure out later that this is really just an issue of our test not testing for the proper behaviour. The cell definition is still correct, it is just the test code that is not flexible enough to realise this. So how could we change that to avoid locking us into this brittleness? We have to change the code within the for loop section, i.e.

// requires #include <algorithm> for std::find
for (int zone = 0; zone < cells.size(); ++zone) {
  for (int cell = 0; cell < cells[zone].size(); ++cell) {
    for (int vertex = 0; vertex < cells[zone][cell].size(); ++vertex) {
      auto cellStart = cells[zone][cell].begin();
      auto cellEnd = cells[zone][cell].end();
      auto vertexInCell = std::find(cellStart, cellEnd, testCells[zone][cell][vertex]) != cellEnd;
      ASSERT_TRUE(vertexInCell);
    }
  }
}

Here, we essentially check if the current vertex that we access from testCells[zone][cell][vertex] is anywhere in the cells std::vector. This is shown on line 7. If it is, then this call will return true, and if it isn’t, this line will be evaluated to false. We simply check on the following line that we indeed have found the current vertex somewhere in the cell, and this provides us with more protection against brittleness.

Having said all that, we get away in this case by not using this more elaborate testing because the grid files will not change over time, so we do not expect this test to suddenly fail. But it provides us with a chance to sensibilise us for the issue of brittle tests when we write our own. They easily make their way into our test suite and we need to make an effort trying to stop them whenever we can.

Interface reading test

Following again the same structure of looping over our unstructured meshes, we see the same assertions done on lines 9-17 before we start looping over the zones and interfaces. Remember that for unstructured grids, we were reading the interfaces on a per-zone basis, so we loop over the zones and then check the interfaces for each zone, of which there is one per zone in this case.

TEST_F(ReadUnstructuredMeshTest, readInterfaces) {
  for (const auto& mesh : unstructuredMesh) {
    // Arrange

    // Act
    auto meshInterfaces = mesh.getInterfaceConnectivity();

    // Assert
    ASSERT_EQ(meshInterfaces.size(), 2);
    ASSERT_EQ(meshInterfaces[0].size(), 1);
    ASSERT_EQ(meshInterfaces[1].size(), 1);

    ASSERT_EQ(meshInterfaces[0][0].zones[0], 0);
    ASSERT_EQ(meshInterfaces[0][0].zones[1], 1);

    ASSERT_EQ(meshInterfaces[1][0].zones[0], 0);
    ASSERT_EQ(meshInterfaces[1][0].zones[1], 1);

    for (int zone = 0; zone < meshInterfaces.size(); ++zone) {
      for (int interface = 0; interface < meshInterfaces[zone].size(); ++interface) {
        ASSERT_EQ(meshInterfaces[zone][interface].ownerIndex.size(), 2);
        ASSERT_EQ(meshInterfaces[zone][interface].neighbourIndex.size(), 2);
        for (int index = 0; index < meshInterfaces[zone][interface].ownerIndex.size(); ++index) {
          auto receivedOwnerStart = meshInterfaces[zone][interface].ownerIndex[index][0];
          auto expectedOwnerStart = testInterfaces[zone][interface][0][index][0];
          ASSERT_EQ(receivedOwnerStart, expectedOwnerStart);

          auto receivedOwnerEnd = meshInterfaces[zone][interface].ownerIndex[index][1];
          auto expectedOwnerEnd = testInterfaces[zone][interface][0][index][1];
          ASSERT_EQ(receivedOwnerEnd, expectedOwnerEnd);

          auto receivedNeighbourStart = meshInterfaces[zone][interface].neighbourIndex[index][0];
          auto expectedNeighbourStart = testInterfaces[zone][interface][1][index][0];
          ASSERT_EQ(receivedNeighbourStart, expectedNeighbourStart);

          auto receivedNeighbourEnd = meshInterfaces[zone][interface].neighbourIndex[index][1];
          auto expectedNeighbourEnd = testInterfaces[zone][interface][1][index][1];
          ASSERT_EQ(receivedNeighbourEnd, expectedNeighbourEnd);
        }
      }
    }
  }
}

From lines 19-41, we test each interface. First, on lines 21-22, we ensure that we have exactly 2 cells (faces) in our interface. Since we only have a single interface in this case, where both sides have 2 faces in it, we can hard-code this value here within the loop. We then loop over all the vertices in the interface and check that both the start and end vertex, which make up our 1D bar element on the interface, are the same for the owning and neighbouring zones.

Is this a brittle test? Is the order of the elements important in the interface or could they be randomly ordered? For me, the answer is, that this is not a brittle test and yes, the order is important. In the case of the cell test above, the order was not important, as the vertices did not depend on anything. In the interface case, changing the order on one side of the interface means that the order on the other interface would need to change as well, and so we want to have some form of order.

Boundary condition reading test

The boundary reading test is again very similar to the interface reading test, and given that the interfaces were stored on a per-zone basis, the only difference here is that the boundary condition will not store information about a neighbouring zone, but instead store the type of boundary condition that should be applied for the boundary elements.

So we loop over the grids again, and this time the difference in the grids is actually important since it is the boundaries for which we have changes in how the data is read. It should not affect us as users, i.e. the ones calling the mesh reading library functions, as we just care about the final boundary condition data, which is going to be presented to us in the same format, regardless of where the information was read from. Thus, the test is the same for both cases.

TEST_F(ReadUnstructuredMeshTest, readBoundaryConditions) {
  for (const auto& mesh : unstructuredMesh) {
    // Arrange

    // Act
    auto bc = mesh.getBoundaryConditions();

    // Assert
    ASSERT_EQ(bc.size(), 2);

    ASSERT_EQ(bc[0][0].boundaryType, BC::INLET);
    ASSERT_EQ(bc[0][1].boundaryType, BC::SYMMETRY);
    ASSERT_EQ(bc[0][2].boundaryType, BC::WALL);

    ASSERT_EQ(bc[1][0].boundaryType, BC::OUTLET);
    ASSERT_EQ(bc[1][1].boundaryType, BC::SYMMETRY);
    ASSERT_EQ(bc[1][2].boundaryType, BC::WALL);

    for (int zone = 0; zone < bc.size(); ++zone) {
      ASSERT_EQ(bc[zone].size(), 3);
      for (int boundary = 0; boundary < bc[zone].size(); ++boundary) {
        ASSERT_EQ(bc[zone][boundary].indices.size(), 2);
        for (int index = 0; index < bc[zone][boundary].indices.size(); ++index) {
          auto receivedStart = bc[zone][boundary].indices[index][0];
          auto expectedStart = testBoundaryConditions[zone][boundary][index][0];
          ASSERT_EQ(receivedStart, expectedStart);

          auto receivedEnd = bc[zone][boundary].indices[index][1];
          auto expectedEnd = testBoundaryConditions[zone][boundary][index][1];
          ASSERT_EQ(receivedEnd, expectedEnd);
        }
      }
    }
  }
}

We check that we have again boundary condition data for two zones (line 9) and then check the various boundary condition types for each boundary on lines 11-17. We then loop over each zone and boundary within each zone and start checking that each boundary face contains the correct vertices.

Test execution

Finally, once we have finished writing our mesh reading tests, we want to execute the test suite to check that all of them are working correctly. To do that, run one of the build scripts we developed above, any one of them will build the test and library code, produce a test executable and run it.

As we have discussed above, when dealing with separate test code to your library, it is very useful to compile your entire codebase as a single or separate library/libraries. This will make it easy to inject your library code into a test suite and is also part of the reason why we have looked at writing libraries in more detail in the previous two series. Being able to compartmentalise your code into libraries makes it easier to develop and test individual sections and then later put them together to create a CFD solver or some other CFD tool we may be interested in developing.

So, if we have followed up until this point and executed one of the build scripts, you should see an output similar to the one shown below.

[==========] Running 7 tests from 2 test suites.
[----------] Global test environment set-up.
[----------] 3 tests from ReadStructuredMeshWithFamilyTest
[ RUN      ] ReadStructuredMeshWithFamilyTest.readCoordinates
[       OK ] ReadStructuredMeshWithFamilyTest.readCoordinates (22 ms)
[ RUN      ] ReadStructuredMeshWithFamilyTest.readInterfaces
[       OK ] ReadStructuredMeshWithFamilyTest.readInterfaces (17 ms)
[ RUN      ] ReadStructuredMeshWithFamilyTest.readBoundaryConditions
[       OK ] ReadStructuredMeshWithFamilyTest.readBoundaryConditions (38 ms)
[----------] 3 tests from ReadStructuredMeshWithFamilyTest (81 ms total)

[----------] 4 tests from ReadUnstructuredMeshTest
[ RUN      ] ReadUnstructuredMeshTest.readCoordinates
[       OK ] ReadUnstructuredMeshTest.readCoordinates (32 ms)
[ RUN      ] ReadUnstructuredMeshTest.readInternalCells
[       OK ] ReadUnstructuredMeshTest.readInternalCells (28 ms)
[ RUN      ] ReadUnstructuredMeshTest.readInterfaces
[       OK ] ReadUnstructuredMeshTest.readInterfaces (29 ms)
[ RUN      ] ReadUnstructuredMeshTest.readBoundaryConditions
[       OK ] ReadUnstructuredMeshTest.readBoundaryConditions (59 ms)
[----------] 4 tests from ReadUnstructuredMeshTest (156 ms total)

[----------] Global test environment tear-down
[==========] 7 tests from 2 test suites ran. (243 ms total)
[  PASSED  ] 7 tests.

All 7 tests are passing (3 for the structured grid tests, and 4 for the unstructured grids), and we can see that in this particular instance, the test code took 243 milliseconds to execute. Your result may vary. If we contrast that with the tests we were writing in the previous article for our complex number class example, where it took 12 milliseconds to execute 8 tests, we can see quite a large difference. So the question is, is 243 milliseconds a good or bad number?

Well, I would see it this way, once we start putting everything together into a larger CFD code, we are going to have quite a bit more of these larger unit tests as we have seen in this article. So if you have several tests that each take around 0.25 seconds or so, this will quickly add up. If your entire test suite will take 5 seconds to execute, we are probably OK with it, but we are going to be annoyed if we are developing a feature and are trying to compile and test our code frequently.

In this case, we can’t do anything about it, we just have to accept that the test time is getting rather large and that all tests combined later may take several seconds to execute. This may not seem like a problem now but you will realise that once the tests take some time to compile and execute, you will run them less frequently and allow for bugs to slip in. You will see this when you start to work on a larger project.

So, in this case, it is again good if we have segregated our code into different libraries so that we can only execute the tests of the functions we have changed, keeping all test code to below a second or so. This will speed up development and will encourage us to run our tests often.

Summary

If you made all the way to the end, congratulations, you pretty much are a gtest wizard at this point. There is not much more to it than what we saw in this article, although gtest allows you to fine-tune your tests with various other functionalities which we have not looked at thus far (and I believe are not essential if you are just getting started). We can always look into the documentation alter if we want to learn what else gtest can do for us.

This article purposefully went over the test code itself rather quickly, as we have already looked at the tests in much greater detail when we developed the original tests for the mesh reading library. I wanted to highlight here primarily how to transform these tests, using a test fixture in this case (a class that sets up some data structure to be reused by several tests), and how the test structure using the arrange, act, assert pattern pretty much always follows the same pattern.

If you feel that by the end of this article, the tests were getting very repetitive and that I was repeating myself, then I have done my job right because that is what I was aiming for. If you feel this was repetitive, then you got it and know how to use gtest.

Tests will not just help you ensure that your code works correctly, but they also play an important part in the documentation process; looking at tests, we can see the intended use of a specific class and which functions ought to be called to get a certain output. I have seen people writing their own unit tests for libraries they intend to use, not to check that the library is working correctly, but to check their understanding and see if what they expect the library to produce is what they receive from it. We could call these learning tests, rather than unit tests.

I would encourage you to try to make it a habit of using gtest or another testing framework for your C++ projects from now on. It will give your code more credibility and ensure it is working correctly while allowing other users to learn from your tests as well. In the next article, we will extend our testing knowledge on gtest by looking at the linear algebra solver library which we developed in a previous series. This will allow us to look at integration and system tests as well, at which point we know everything there is for integrating testing into your projects.


Tom-Robin Teschner is a senior lecturer in computational fluid dynamics and course director for the MSc in computational fluid dynamics and the MSc in aerospace computational engineering at Cranfield University.