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

Proposed testing framework for Standard Library facilities #1

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion .ci/docker/rockylinux.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ RUN dnf install -y \
clang \
g++ \
ninja-build \
cmake
cmake \
python3.11-venv
RUN dnf clean all

# Copy code
Expand Down
3 changes: 2 additions & 1 deletion .ci/docker/ubuntu.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ RUN apt-get install -y \
clang-tidy \
g++ \
ninja-build \
cmake
cmake \
python3.11-venv
RUN apt-get clean

WORKDIR /workarea
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cxx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,4 @@ jobs:
cache-to: type=gha,mode=max
- name: Run tests
run: |
docker run ${{ matrix.cfg.id }} ctest --test-dir build
docker run ${{ matrix.cfg.id }} ctest --test-dir build --output-on-failure
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,61 @@ cmake -B /some/build/dir -S . -DCMAKE_CXX_CLANG_TIDY="clang-tidy;-checks=-*,cppc
Otherwise follow the Basic Build workflow as described above.


### Testing

Tests are written using `lit`, the [LLVM Integrated Tester][]. Each test is an
independent executable with a `main` function. Tests are written in a pretty
basic way, using `assert`. If you want, you can use an arbitrary testing framework
on top of that. However, we recommend against doing so because keeping the tests
basic makes them very portable, and in particular it makes them compatible with
the LLVM C++ Standard Library conformance test suite developed as part of libc++,
which is also reused by other implementations like the MSVC STL and libstdc++.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using assert precludes test runs under the -DNDEBUG flag. Wouldn't that pose a problem?


#### Writing tests

Tests are written as standalone executables with a `main` function. Tested conditions
are asserted using simple `assert(...)` statements. Various test flavors are supported:

- `.pass.cpp`
These tests are built and run. The test succeeds if the program terminates normally
with a `0` exit code. The test fails if it doesn't compile, link or if it exits with
an error.
- `.verify.cpp`
These tests run using `clang-verify`. This allows checking that specific diagnostics
are being emitted at compile-time.

Clang-verify supports various directives like `expected-error`, `expected-warning`, etc.
The full set of directives supported and how to use them is documented in [the Clang
documentation](https://clang.llvm.org/docs/InternalsManual.html#specifying-diagnostics).
- `.compile.pass.cpp`
These tests don't run, they only compile. The test passes if the program compiles, and
fails otherwise. This can be used to test things like `aliases` or concept conformance,
which don't need to actually run anything.
- `.sh.pass.cpp`
These tests run whatever shell commands are specified in the `RUN` commands specified
in the test. This provides a lot of flexibility for controlling how the test gets built
and run, and can be used to check things that are otherwise difficult to test (e.g.
compatibility between TUs built with different Standard modes).

#### Running tests

To run all the tests in the project, use:

```shell
$ ctest --test-dir /some/build/dir
```

You can run a single test in the project by using `lit` directly:

```shell
$ /some/build/dir/test/venv/bin/lit /some/build/dir/test/example-paper/test1.pass.cpp
```

That's not a typo, you must use the `/some/build/dir` directory as a prefix to the
path of the test you want to run. This lets `lit` find the testing configuration
that was generated by CMake.


## Usage

### From C++
Expand Down Expand Up @@ -141,6 +196,8 @@ Please do! Issues and pull requests are appreciated.
Note that adding more C++ code will be out of scope for this project. Changes that further improve or simplify this project given that goal are appreciated. Enhancements to better support packaging ecosystems would also make sense.


[LLVM Integrated Tester]: https://llvm.org/docs/CommandGuide/lit.html

<!--
Creative Commons Legal Code
Expand Down
13 changes: 13 additions & 0 deletions src/example/example.hxx
Original file line number Diff line number Diff line change
@@ -1,2 +1,15 @@
// Copyright © 2024 Bret Brown
// SPDX-License-Identifier: MIT

#ifndef EXAMPLE_HXX
#define EXAMPLE_HXX

#include <type_traits>

template <class T>
constexpr bool foo() {
static_assert(std::is_trivially_copyable_v<T>);
return true;
}

#endif
30 changes: 26 additions & 4 deletions test/example/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
add_executable(example.test)
target_sources(example.test PRIVATE example.test.cxx)
target_link_libraries(example.test PRIVATE example::example)
# Setup the `lit` tool
add_custom_command(
OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/venv/bin/lit"
COMMAND python3 -m venv "${CMAKE_CURRENT_BINARY_DIR}/venv"
COMMAND "${CMAKE_CURRENT_BINARY_DIR}/venv/bin/pip" install --upgrade pip
COMMAND "${CMAKE_CURRENT_BINARY_DIR}/venv/bin/pip" install --upgrade lit
)

# Setup the test suite configuration
configure_file("${CMAKE_SOURCE_DIR}/test/support/lit.cfg.in"
"${CMAKE_CURRENT_BINARY_DIR}/lit.cfg")

add_custom_target(test-depends
COMMAND true
DEPENDS "${CMAKE_CURRENT_BINARY_DIR}/venv/bin/lit"
"${CMAKE_CURRENT_BINARY_DIR}/lit.cfg"
example
COMMENT "Setup the test dependencies"
)

add_test(
NAME setup-tests
COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" --target test-depends
)

Comment on lines +21 to +24
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awkward. It looks like a CTest test cannot depend on a add_custom_target, so I need to do things in this super roundabout way to ensure that the test suite is set up before the tests actually start running.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no guarantee that ctest will run setup-tests before example.test. It might do that by default but there is a ctest option to run the tests in a random order. Consider dropping the setup-tests test and renaming the test-depends target example.test. This would then emulate the usual pattern followed when the test is an actual executable and not a call to a python script.

$ cmake --build --target example.test
$ ctest -R example.test

But then again, that might be misleading a user to think the example.test target is an executable. I agree, it's awkward.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CMake doesn't provide a way to indicate tests have dependencies so the prevailing practice is for tests to assume all targets part of the default target are built.

To ensure test-depends is part of the default target, you need only add the ALL keyword to the add_custom_target call.

add_test(
NAME example.test
COMMAND example.test
COMMAND "${CMAKE_CURRENT_BINARY_DIR}/venv/bin/lit" -sv "${CMAKE_CURRENT_BINARY_DIR}"
)
set_tests_properties(example.test PROPERTIES DEPENDS setup-tests)
5 changes: 0 additions & 5 deletions test/example/example.test.cxx

This file was deleted.

14 changes: 14 additions & 0 deletions test/example/test1.pass.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// This test gets built and run. The test succeeds if the program
// terminates normally with a 0 exit code. The test fails if it
// doesn't compile, link or if it exits with an error.
//

#include <cassert>
#include <example.hxx>

int main(int, char**) {
assert(foo<int>());

return 0;
}
15 changes: 15 additions & 0 deletions test/example/test2.verify.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// This test runs using clang-verify. This allows checking that specific
// diagnostics are being emitted at compile-time.
//
// Clang-verify supports various directives like 'expected-error',
// 'expected-warning', etc. The full set of directives supported and
// how to use them is documented in https://clang.llvm.org/docs/InternalsManual.html#specifying-diagnostics.
//

#include <string>
#include <example.hxx>

void f() {
foo<std::string>(); // expected-error@*:* {{static assertion failed due to requirement 'std::is_trivially_copyable_v}}
}
8 changes: 8 additions & 0 deletions test/example/test3.compile.pass.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// This test doesn't run, it only compiles.
// The test passes if the program compiles, and fails otherwise.
//

#include <example.hxx>

static_assert(foo<int>());
24 changes: 24 additions & 0 deletions test/example/test4.sh.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// This test runs whatever shell commands are specified in the RUN commands
// below. This provides a lot of flexibility for controlling how the test
// gets built and run.
//

// RUN: %{cxx} %{flags} %s -DTRANSLATION_UNIT_1 -c -o %t.tu1.o
// RUN: %{cxx} %{flags} %s -DTRANSLATION_UNIT_2 -c -o %t.tu2.o
// RUN: %{cxx} %{flags} %t.tu1.o %t.tu2.o -o %t.exe
// RUN: %t.exe

#include <example.hxx>

#ifdef TRANSLATION_UNIT_1
void f() { }
#endif

#ifdef TRANSLATION_UNIT_2
extern void f();

int main() {
f();
}
#endif
21 changes: 21 additions & 0 deletions test/support/lit.cfg.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import site, os
site.addsitedir(os.path.join('@CMAKE_SOURCE_DIR@', 'test', 'support'))
import testformat

config.name = 'Beman project test suite'
config.test_format = testformat.CxxStandardLibraryTest()
config.test_exec_root = '@CMAKE_CURRENT_BINARY_DIR@'
config.test_source_root = '@CMAKE_CURRENT_SOURCE_DIR@'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usage of CMAKE_CURRENT_BINARY_DIR and CMAKE_CURRENT_SOURCE_DIR is confusing here since these are unrelated to the location of lit.cfg.in. I'd suggest making some new variables on the CMake side and using them here.

So, this file would instead be...

config.test_exec_root = '@LIT_TEST_EXEC_ROOT@'
config.test_source_root = '@LIT_TEST_SOURCE_ROOT@'

And test/example/CMakeLists.txt would turn into something like this...

block()
  # Setup the test suite configuration
  set(LIT_TEST_EXEC_ROOT ${CMAKE_CURRENT_BINARY_DIR})
  set(LIT_TEST_SOURCE_ROOT ${CMAKE_CURRENT_SOURCE_DIR})
  configure_file("${CMAKE_SOURCE_DIR}/test/support/lit.cfg.in"
                 "${CMAKE_CURRENT_BINARY_DIR}/lit.cfg")
endblock()

flags = [
'-std=c++20',
'-isysroot @CMAKE_OSX_SYSROOT@' if '@CMAKE_OSX_SYSROOT@' else '',
'-isystem {}'.format(os.path.join('@CMAKE_SOURCE_DIR@', 'src', 'example')),
'@CMAKE_CXX_FLAGS@'
]
Comment on lines +9 to +14

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the project under test is more complex, I expect that there will be transitive usage requirements that need to get passed to the compilation flags here. Do those need to be repeated manually or is there a way to query cmake?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing that needs to be verified is cross-compilation support.

config.substitutions = [
('%{cxx}', '@CMAKE_CXX_COMPILER@'),
('%{flags}', ' '.join(filter(None, flags)))
]
config.available_features = []
if '@CMAKE_CXX_COMPILER_ID@' in ('Clang', 'AppleClang'):
config.available_features.append('verify-support')
91 changes: 91 additions & 0 deletions test/support/testformat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import lit
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For information, I am working on some LLVM changes that would render this file unnecessary. Basically we would just install Lit and the Lit package would contain exactly the same test format as the one used by libc++.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where are you at with these changes?

import os
import re

def _parseScript(test, preamble):
"""
Extract the script from a test, with substitutions applied.

Returns a list of commands ready to be executed.

- test
The lit.Test to parse.

- preamble
A list of commands to perform before any command in the test.
These commands can contain unexpanded substitutions, but they
must not be of the form 'RUN:' -- they must be proper commands
once substituted.
"""
# Get the default substitutions
tmpDir, tmpBase = lit.TestRunner.getTempPaths(test)
substitutions = lit.TestRunner.getDefaultSubstitutions(test, tmpDir, tmpBase)

# Parse the test file, including custom directives
scriptInTest = lit.TestRunner.parseIntegratedTestScript(test, require_script=not preamble)
if isinstance(scriptInTest, lit.Test.Result):
return scriptInTest

script = preamble + scriptInTest
return lit.TestRunner.applySubstitutions(script, substitutions, recursion_limit=10)

class CxxStandardLibraryTest(lit.formats.FileBasedTest):
def getTestsForPath(self, testSuite, pathInSuite, litConfig, localConfig):
SUPPORTED_SUFFIXES = [
"[.]pass[.]cpp$",
"[.]compile[.]pass[.]cpp$",
"[.]sh[.][^.]+$",
"[.]verify[.]cpp$",
]

sourcePath = testSuite.getSourcePath(pathInSuite)
filename = os.path.basename(sourcePath)

# Ignore dot files, excluded tests and tests with an unsupported suffix
hasSupportedSuffix = lambda f: any([re.search(ext, f) for ext in SUPPORTED_SUFFIXES])
if filename.startswith(".") or filename in localConfig.excludes or not hasSupportedSuffix(filename):
return

yield lit.Test.Test(testSuite, pathInSuite, localConfig)

def execute(self, test, litConfig):
supportsVerify = "verify-support" in test.config.available_features
filename = test.path_in_suite[-1]

if re.search("[.]sh[.][^.]+$", filename):
steps = [] # The steps are already in the script
return self._executeShTest(test, litConfig, steps)
elif filename.endswith(".compile.pass.cpp"):
steps = ["%dbg(COMPILED WITH) %{cxx} %s %{flags} -fsyntax-only"]
return self._executeShTest(test, litConfig, steps)
elif filename.endswith(".verify.cpp"):
if not supportsVerify:
return lit.Test.Result(
lit.Test.UNSUPPORTED,
"Test {} requires support for Clang-verify, which isn't supported by the compiler".format(test.getFullName()),
)
steps = ["%dbg(COMPILED WITH) %{cxx} %s %{flags} -fsyntax-only -Xclang -verify -Xclang -verify-ignore-unexpected=note -ferror-limit=0"]
return self._executeShTest(test, litConfig, steps)
elif filename.endswith(".pass.cpp"):
steps = [
"%dbg(COMPILED WITH) %{cxx} %s %{flags} -o %t.exe",
"%dbg(EXECUTED AS) %t.exe",
]
return self._executeShTest(test, litConfig, steps)
else:
return lit.Test.Result(lit.Test.UNRESOLVED, "Unknown test suffix for '{}'".format(filename))

def _executeShTest(self, test, litConfig, steps):
if test.config.unsupported:
return lit.Test.Result(lit.Test.UNSUPPORTED, "Test is unsupported")

script = _parseScript(test, steps)
if isinstance(script, lit.Test.Result):
return script

if litConfig.noExecute:
return lit.Test.Result(lit.Test.XFAIL if test.isExpectedToFail() else lit.Test.PASS)
else:
_, tmpBase = lit.TestRunner.getTempPaths(test)
useExternalSh = False
return lit.TestRunner._runShTest(test, litConfig, useExternalSh, script, tmpBase)
Loading