This is the multi-page printable view of this section. Click here to print.
- 1: Go Testing Guide
- 2: Testrepo
1 - Go Testing Guide
What should be tested in this project?
Given that this CLI is the entry point for the user to interact with Divekit, it is essential to test all commands.
Currently, there is only one command patch
, but all commands should be tested with the following aspects in mind:
- Command Syntax: Verify that the command syntax is correct
- Command Execution: Ensure that executing the command produces the expected behavior or output
- Options and Arguments: Test each option and argument individually to ensure they are processed correctly and test various combinations of options and arguments
- Error Handling: Test how the command handles incorrect syntax, invalid options, or missing arguments
Additionally, testing the utility functions is necessary, as they are used throughout the entire project. For that the following aspects should be considered:
- Code Paths: Every possible path through the code should be tested, which should include “happy paths” (expected input and output) as well as “edge cases” (unexpected inputs and conditions).
- Error Conditions: Check that the code handles error conditions correctly. For example, if a function is supposed to handle an array of items, what happens when it’s given an empty array? What about an array with only one item, or an array with the maximum number of items?
How should something be tested?
Commands should be tested with integration tests since they interact with the entire project. Integration tests are utilized to verify that all components of this project work together as expected in order to test the mentioned aspects.
To detect early bugs, utility functions should be tested with unit tests. Unit tests are used to verify the behavior of specific functionalities in isolation. They ensure that individual units of code produce the correct and expected output for various inputs.
How are tests written in Go?
Prerequisites
It’s worth mentioning that the following packages are utilized in this project for testing code.
The testing package
The standard library provides the testing package, which is required to support testing in Go. It offers different types from the testing library [1, pp. 37-38]:
testing.T
: To interact with the test runner, all tests must use this type. It contains a method for declaring failing tests, skipping tests, and running tests in parallel.testing.B
: Similar to the test runner, this type is a benchmark runner. It shares the same methods for failing tests, skipping tests and running benchmarks concurrently. Benchmarks are generally used to determine performance of written code.testing.F
: This type generates a randomized seed for the testing target and collaborates with thetesting.T
type to provide test-running functionality. Fuzz tests are unique tests that generate random inputs to discover edge cases and identify bugs in written code.testing.M
: This type allows for additional setup or teardown before or after tests are executed.
The testify toolkit
The testify toolkit provides several packages to work with assertions, mock objects and testing suites [4]. Primarily, the assertion package is used in this project for writing assertions more easily.
Test signature
To write unit or integration tests in Go, it is necessary to construct test functions following a particular signature:
func TestName(t *testing.T) {
// implementation
}
According to this test signature highlights following requirements [1, p.40]:
- Exported functions with names starting with “Test” are considered tests.
- Test names can have an additional suffix that specifies what the test is covering. The suffix must also begin with a capital letter. In this case, “Name” is the specified suffix.
- Tests are required to accept a single parameter of the
*testing.T
type. - Tests should not include a return type.
Unit tests
Unit tests are small, fast tests that verify the behavior of specific functionalities in isolation. They ensure that individual units of code produce the correct and expected output for various inputs.
To illustrate unit tests, a new file named divide.go
is generated with the following code:
package main
func Divide(a, b int) float64 {
return float64(a) / float64(b)
}
By convention tests are located in the same package as the function being tested.
It’s important that all test files must end with _test.go
suffix to get detected by the test runner.
Accordingly divide_test.go
is also created within the main package:
package main
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestDivide(t *testing.T) {
// Arrange
should, a, b := 2.5, 5, 2
// Act
is := divide(a, b)
// Assert
assert.Equal(t, should, is, "Got %v, want %v", is, should)
}
Writing unit or integration tests in the Arrange-Act-Assert (AAA) pattern is a common practice. This pattern establishes a standard for writing and reading tests, reducing the cognitive load for both new and existing team members and enhancing the maintainability of the code base [1, p. 14].
In this instance, the test is formulated as follows:
Arrange: All preconditions and inputs get set up.
Act: The Act step executes the actions outlined in the test scenario, with the specific actions depending on the type of test. In this instance, it calls the Add function and utilizes the inputs from the Arrange step.
Assert: During this step, the precondition from the Arrange step is compared with the output. If the output does not match the precondition, the test is considered failed, and an error message is displayed.
It’s worth noting that the Act and Assert steps can be iterated as many times as needed, proving beneficial, particularly in the context of table-driven tests.
Table-driven tests for unit and integration tests
To cover all test cases it is required to call Act and Assert multiple times. It would be possible to write one test per case, but this would lead to a lot of duplication, reducing the readability. An alternative approach is to invoke the same test function several times. However, in case of a test failure, pinpointing the exact point of failure may pose a challenge [2]. Instead, in the table-driven approach, preconditions and inputs are structured as a table in the Arrange step.
As a consequence divide_test.go
gets adjusted in the following steps [1, pp. 104-109]:
Step 1 - Create a structure for test cases
In the first step a custom type is declared within the test function. As an alternative the structure could be declared outside the scope of the test function. The purpose of this structure is to hold the inputs and expected preconditions of the test case.
The test cases for the previously mentioned Divide
function could look like this:
package main
import (
"math"
"testing"
)
func TestDivide(t *testing.T) {
// Arrange
testCases := []struct {
name string // test case name
dividend int // input
divisor int // input
quotient float64 // expected
}{
{"Regular division", 5, 2, 2.5},
{"Divide with negative numbers", 5, -2, -2.5},
{"Divide by 0", 5, 0, math.Inf(1)},
}
}
The struct
type wraps name
, dividend
, divisor
and quotient
. name
describes the purpose of a test case
and can be used to identify a test case, in case an error occurs.
Step 2 - Executing each test and assert it
Each test case from the table will be executed as a subtest. To achieve this, the testCases
are iterated over and
each testCase
is executed in a separate goroutine
[3] with t.Run()
.
The purpose of this is to individually fail tests without concerns about disrupting other tests.
Within t.Run()
, the Act and Assert steps get performed:
package main
import (
"github.com/stretchr/testify/assert"
"math"
"testing"
)
func TestDivide(t *testing.T) {
// Arrange
testCases := []struct {
name string // test case name
dividend int // input
divisor int // input
quotient float64 // expected
}{
{"Regular division", 5, 2, 2.5},
{"Divide with negative numbers", 5, -2, -2.5},
{"Divide by 0", 5, 0, math.Inf(1)},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
// Act
quotient := Divide(testCase.dividend, testCase.divisor)
// Assert
assert.Equal(t, testCase.quotient, quotient)
})
}
}
Setup and teardown
Setup and teardown before and after a test
Setup and teardown are used to prepare the environment for tests and clean up after tests have been executed.
In Go the type testing.M
from the testing package fulfills this purpose and is used as a parameter for the
TestMain
function, which controls the setup and teardown of tests.
To use this function, it must be included within the package alongside the tests, as the scope for functions
is limited to the package in which it is defined. This implies that each package can only have one
TestMain
function; consequently, it is called only when a test is executed within the package
[5].
The following example illustrates how it works [1, p. 51]:
package main
func TestMain(m *testing.M) {
// setup statements
setup()
// run the tests
e := m.Run()
// cleanup statements
teardown()
// report the exit code
os.Exit(e)
}
func setup() {
log.Println("Setting up.")
}
func teardown() {
log.Println("Tearing down.")
}
TestMain
runs before any tests are executed and defines the setup
and teardown
functions. The Run
method
from testing.M
is used to invoke the tests and returns an exit code that is used to report the success or failure
of the tests.
Setup and teardown before and after each test
In order to teardown after each test, the t.Cleanup
function can be used provided by the testing package
[2].
Since there is no mention to setup
before each test, it can be assumed that the setup
function is
called at the start of a test.
This example shows how this can be used:
package main
func TestWithSetupAndCleanup(t *testing.T) {
setup()
t.Cleanup(func() {
// cleanup logic
})
// more test code here
}
Write integration tests
Integration tests are used to verify the interaction between different components of a system. However, the mentioned principles for writing unit tests also apply to integration tests. The only difference is that integration tests involve a greater amount of code, as they encompass multiple components.
How to run tests?
To run tests from the CLI, the go test
command is used, which is part of the Go toolchain
[6].
The list shows some examples of how to run tests:
To run a specific test, the
-run
flag can be used. For example, to run theTestDivide
test from thedivide_test.go
file, the following command can be used:go test -run TestDivide
. Note that the argument for-run
is a regular expression, so it is possible to run multiple tests at once.To run all tests in a package, run
go test <packageName>
. Note that the package name should include a relative path if the package is not in the working directory.To run all tests in a project, run
go test ./...
. The argument for test is a wildcard, matching all subdirectories; therefore, it is crucial for the working directory to be set to the root of the project to recursively run all tests.
Additionally, tests can be run from the IDE. For example, in GoLand, the IDE will automatically detect tests and provide a gutter icon to run them [7].
How the command patch
is tested?
Prerequisites
Before patch
can be tested, it is necessary to do the following:
- Replace the placeholders in the file
.env.example
and rename it to.env
. If you have no api token, you can generate one here. - Run the script
setup.ps1
as administrator. This script will install all necessary dependencies and initialize the ARS-, Repo-Editor- and Test-Origin-Repository.
Test data
To test patch
, it was necessary to use a
test origin repository as test data. In this context the test origin repository is a repository that contains
all the necessary files and configurations from ST1 to test different scenarios.
Additionally, a test group was created to test if the Repo-Editor-repository actually pushes the generated files to remote repositories. Currently, the test group contains the following repositories:
coderepos:
ST1_Test_group_8063661e-3603-4b84-b780-aa5ff1c3fe7d
ST1_Test_group_86bd537d-9995-4c92-a6f4-bec97eeb7c67
ST1_Test_group_8754b8cb-5bc6-4593-9cb8-7c84df266f59
testrepos:
ST1_Test_tests_group_446e3369-ed35-473e-b825-9cc0aecd6ba3
ST1_Test_tests_group_9672285a-67b0-4f2e-830c-72925ba8c76e
Structure of a test case
patch
is tested with a table-driven test, which is located in the file patch_test.go
.
The following example shows the structure of a test case:
package patch
func TestPatch(t *testing.T) {
testCases := []struct {
name string
arguments PatchArguments // input
generatedFiles []GeneratedFile // expected
error error // expected
}{
{
"example test case",
PatchArguments{
dryRun: true | false,
logLevel: "[empty] | info | debug | warning | error",
originRepo: "path_to_test_origin_repo",
home: "[empty] | path_to_repositories",
distribution: "[empty] | code | test",
patchFiles: []string{"patch_file_name"},
},
[]GeneratedFile{
{
RepoName: "repository_name",
RelFilePath: "path_to_the_generated_file",
Distribution: Code | Test,
Include: []string{"should_be_found_in_the_generated_file"},
Exclude: []string{"should_not_be_found_in_the_generated_file"},
},
},
error: nil | errorType,
},
}
// [run test cases]
}
The name
field is the name of the test case and is used to identify the test case in case of an error.
The struct PatchArguments
contains all the necessary arguments to run the patch
command:
dryRun
: If true, generated files will not be pushed to a remote repository.logLevel
: The log level of the command.originRepo
: The path to the test origin repository.home
: The path to the divekit repositories.distribution
: The distribution to patch.patchFiles
: The patch files to apply.
The struct GeneratedFile
is the expected result of the patch
command and contains the following properties:
RepoName
: The name of the generated repository.RelFilePath
: The relative file path of the generated file.Distribution
: The distribution of the generated file.Include
: Keywords that should be found in the generated file.Exclude
: Keywords that should not be found in the generated file.
The error
field is the expected error of the patch
command. It can be nil
when no error is expected or
contain a specific error type if an error is expected.
Process of a test case
The following code snippet shows how test cases are processed:
package patch
func TestPatch(t *testing.T) {
// [define test cases]
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
generatedFiles := testCase.generatedFiles
dryRunFlag := testCase.arguments.dryRun
distributionFlag := testCase.arguments.distribution
deleteFilesFromRepositories(t, generatedFiles, dryRunFlag) // step 1
_, err := executePatch(testCase.arguments) // step 2
checkErrorType(t, testCase.error, err) // step 3
if err == nil {
matchGeneratedFiles(t, generatedFiles, distributionFlag) // step 4
checkFileContent(t, generatedFiles) // step 5
checkPushedFiles(t, generatedFiles, dryRunFlag) // step 6
}
})
}
}
Each test case runs the following sequence of steps:
deleteFilesFromRepositories
deletes the specified files from their respective repositories. Prior to testing, it is necessary to delete these files to ensure that they are actually pushed to the repositories, given that they are initially included in the repositories.executePatch
executes the patch command with the given arguments and return the output and the error.checkErrorType
checks if the expected error type matches with the actual error type.matchGeneratedFiles
checks if the found file paths match with the expected files and throws an error when there are any differences.checkFileContent
checks if the content of the files is correct.checkPushedFiles
checks if the generated files have been pushed correctly to the corresponding repositories.
References
[1] A. Simion, Test-Driven Development in Go Packt Publishing Ltd, 2023
[2] “Comprehensive Guide to Testing in Go | The GoLand Blog," The JetBrains Blog (accessed Jan. 29, 2024).
[3] “Goroutines in Golang - Golang Docs," (accessed Jan. 29, 2024).
[4] “Using the Testify toolkit | GoLand," GoLand Help. (accessed Jan. 29, 2024).
[5] “Why use TestMain for testing in Go?" (accessed Jan. 29, 2024).
[6] “Go Toolchain - Go Wiki” (accessed Jan. 29, 2024).
[7] “Run tests | GoLand," GoLand Help. (accessed Jan. 29, 2024).
2 - Testrepo
The documentation is not yet written. Feel free to add it yourself ;)
Testing Package structure
static final String PACKAGE_PREFIX = "thkoeln.divekit.archilab.";
@Test
public void testPackageStructure() {
try {
Class.forName(PACKAGE_PREFIX + "domainprimitives.StorageCapacity");
Class.forName(PACKAGE_PREFIX + "notebook.application.NotebookDto");
Class.forName(PACKAGE_PREFIX + "notebook.application.NotebookController");
Class.forName(PACKAGE_PREFIX + "notebook.domain.Notebook");
// using individualization and the variableExtensionConfig.json this could be simplified to
// Class.forName("$entityPackage$.domain.$entityClass$");
// ==> Attention: If used, the test can't be tested in the orgin repo itself
} catch (ClassNotFoundException e) {
Assertions.fail("At least one of your entities is not in the right package, or has a wrong name. Please check package structure and spelling!");
}
}
Testing REST Controller
@Autowired
private MockMvc mockMvc;
@Test
public void notFoundTest() throws Exception {
mockMvc.perform(get("/notFound")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isNotFound());
}
@Transactional
@Test
public void getPrimeNumberTest() throws Exception {
final Integer expectedPrimeNumber = 13;
mockMvc.perform(get("/primeNumber")
.accept(MediaType.APPLICATION_JSON))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$", Matchers.is(expectedPrimeNumber))).andReturn();
}