tests(refactor): Improve consistency and documentation for test helpers (#3012)

This commit is contained in:
Georg Lauterbach 2023-01-22 00:05:28 +01:00 committed by GitHub
parent fb82082cf1
commit e3c4ef76c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 936 additions and 656 deletions

View file

@ -1,5 +1,19 @@
#!/bin/bash
# ? ABOUT: Functions defined here aid with common functionality during tests.
# ! ATTENTION: Functions prefixed with `__` are intended for internal use within this file only, not in tests.
# ! -------------------------------------------------------------------
# ? >> Miscellaneous initialization functionality
# shellcheck disable=SC2155
# Load additional BATS libraries for more functionality.
#
# ## Note
#
# This function is internal and should not be used in tests.
function __load_bats_helper() {
load "${REPOSITORY_ROOT}/test/test_helper/bats-support/load"
load "${REPOSITORY_ROOT}/test/test_helper/bats-assert/load"
@ -7,241 +21,377 @@ function __load_bats_helper() {
__load_bats_helper
# -------------------------------------------------------------------
# like _run_in_container_explicit but infers ${1} by using the ENV CONTAINER_NAME
# WARNING: Careful using this with _until_success_or_timeout methods,
# which can be misleading in the success of `run`, not the command given to `run`.
function _run_in_container() {
run docker exec "${CONTAINER_NAME}" "${@}"
# Properly handle the container name given to tests. This makes the whole
# test suite more robust as we can be sure that the container name is
# properly set. Sometimes, we need to provide an explicit container name;
# this function eases the pain by either providing the explicitly given
# name or `CONTAINER_NAME` if it is set.
#
# @param ${1} = explicit container name [OPTIONAL]
#
# ## Attention
#
# Note that this function checks whether the name given to it starts with
# the prefix `dms-test_`. One must adhere to this naming convention.
#
# ## Panics
#
# If neither an explicit non-empty argument is given nor `CONTAINER_NAME`
# is set.
#
# ## "Calling Convention"
#
# This function should be called the following way:
#
# local SOME_VAR=$(__handle_container_name "${X:-}")
#
# Where `X` is an arbitrary argument of the function you're calling.
#
# ## Note
#
# This function is internal and should not be used in tests.
function __handle_container_name() {
if [[ -n ${1:-} ]] && [[ ${1:-} =~ ^dms-test_ ]]
then
printf '%s' "${1}"
return 0
elif [[ -n ${CONTAINER_NAME+set} ]]
then
printf '%s' "${CONTAINER_NAME}"
return 0
else
echo 'ERROR: (helper/common.sh) Container name was either provided explicitly without the required `dms-test_` prefix, or CONTAINER_NAME is not set for implicit usage' >&2
exit 1
fi
}
# @param ${1} container name [REQUIRED]
# @param ... command to execute
function _run_in_container_explicit() {
local CONTAINER_NAME=${1:?Container name must be given when using explicit}
# ? << Miscellaneous initialization functionality
# ! -------------------------------------------------------------------
# ? >> Functions to execute commands inside a container
# Execute a command inside a container with an explicit name.
#
# @param ${1} = container name
# @param ... = command to execute
function _exec_in_container_explicit() {
local CONTAINER_NAME=${1:?Container name must be provided when using explicit}
shift 1
run docker exec "${CONTAINER_NAME}" "${@}"
docker exec "${CONTAINER_NAME}" "${@}"
}
function _default_teardown() {
docker rm -f "${CONTAINER_NAME}"
# Execute a command inside the container with name ${CONTAINER_NAME}.
#
# @param ... = command to execute
function _exec_in_container() {
_exec_in_container_explicit "${CONTAINER_NAME:?Container name must be provided}" "${@}"
}
function _reload_postfix() {
local CONTAINER_NAME=${1:-${CONTAINER_NAME}}
# Reloading Postfix config after modifying it in <2 sec will cause Postfix to delay, workaround that:
docker exec "${CONTAINER_NAME}" touch -d '2 seconds ago' /etc/postfix/main.cf
docker exec "${CONTAINER_NAME}" postfix reload
# Execute a command inside a container with an explicit name. The command is run with
# BATS' `run` so you can check the exit code and use `assert_`.
#
# @param ${1} = container name
# @param ... = command to execute
function _run_in_container_explicit() {
local CONTAINER_NAME=${1:?Container name must be provided when using explicit}
shift 1
run _exec_in_container_explicit "${CONTAINER_NAME}" "${@}"
}
# -------------------------------------------------------------------
# @param ${1} target container name [IF UNSET: ${CONTAINER_NAME}]
function get_container_ip() {
local TARGET_CONTAINER_NAME=${1:-${CONTAINER_NAME}}
docker inspect --format '{{ .NetworkSettings.IPAddress }}' "${TARGET_CONTAINER_NAME}"
# Execute a command inside the container with name ${CONTAINER_NAME}. The command
# is run with BATS' `run` so you can check the exit code and use `assert_`.
#
# @param ... = command to execute
function _run_in_container() {
_run_in_container_explicit "${CONTAINER_NAME:?Container name must be provided}" "${@}"
}
# -------------------------------------------------------------------
# Execute a command inside the container with name ${CONTAINER_NAME}. Moreover,
# the command is run by Bash with `/bin/bash -c`.
#
# @param ... = command to execute with Bash
function _exec_in_container_bash() { _exec_in_container /bin/bash -c "${@}" ; }
# @param ${1} timeout
# @param --fatal-test <command eval string> additional test whose failure aborts immediately
# @param ... test to run
function repeat_until_success_or_timeout {
# Execute a command inside the container with name ${CONTAINER_NAME}. The command
# is run with BATS' `run` so you can check the exit code and use `assert_`. Moreover,
# the command is run by Bash with `/bin/bash -c`.
#
# @param ... = Bash command to execute
function _run_in_container_bash() { _run_in_container /bin/bash -c "${@}" ; }
# ? << Functions to execute commands inside a container
# ! -------------------------------------------------------------------
# ? >> Functions about executing commands with timeouts
# Repeats a given command inside a container until the timeout is over.
#
# @param ${1} = timeout
# @param ${2} = container name
# @param ... = test command for container
function _repeat_in_container_until_success_or_timeout() {
local TIMEOUT="${1:?Timeout duration must be provided}"
local CONTAINER_NAME="${2:?Container name must be provided}"
shift 2
_repeat_until_success_or_timeout \
--fatal-test "_container_is_running ${CONTAINER_NAME}" \
"${TIMEOUT}" \
_exec_in_container "${@}"
}
# Repeats a given command until the timeout is over.
#
# @option --fatal-test <COMMAND EVAL STRING> = additional test whose failure aborts immediately
# @param ${1} = timeout
# @param ... = test to run
function _repeat_until_success_or_timeout() {
local FATAL_FAILURE_TEST_COMMAND
if [[ "${1}" == "--fatal-test" ]]; then
FATAL_FAILURE_TEST_COMMAND="${2}"
if [[ "${1:-}" == "--fatal-test" ]]
then
FATAL_FAILURE_TEST_COMMAND="${2:?Provided --fatal-test but no command}"
shift 2
fi
if ! [[ "${1}" =~ ^[0-9]+$ ]]; then
echo "First parameter for timeout must be an integer, received \"${1}\""
local TIMEOUT=${1:?Timeout duration must be provided}
shift 1
if ! [[ "${TIMEOUT}" =~ ^[0-9]+$ ]]
then
echo "First parameter for timeout must be an integer, received \"${TIMEOUT}\""
return 1
fi
local TIMEOUT=${1}
local STARTTIME=${SECONDS}
shift 1
until "${@}"
do
if [[ -n ${FATAL_FAILURE_TEST_COMMAND} ]] && ! eval "${FATAL_FAILURE_TEST_COMMAND}"; then
if [[ -n ${FATAL_FAILURE_TEST_COMMAND} ]] && ! eval "${FATAL_FAILURE_TEST_COMMAND}"
then
echo "\`${FATAL_FAILURE_TEST_COMMAND}\` failed, early aborting repeat_until_success of \`${*}\`" >&2
return 1
fi
sleep 1
if [[ $(( SECONDS - STARTTIME )) -gt ${TIMEOUT} ]]; then
if [[ $(( SECONDS - STARTTIME )) -gt ${TIMEOUT} ]]
then
echo "Timed out on command: ${*}" >&2
return 1
fi
done
}
# like repeat_until_success_or_timeout but with wrapping the command to run into `run` for later bats consumption
# @param ${1} timeout
# @param ... test command to run
function run_until_success_or_timeout {
if ! [[ ${1} =~ ^[0-9]+$ ]]; then
echo "First parameter for timeout must be an integer, received \"${1}\""
# Like `_repeat_until_success_or_timeout` . The command is run with BATS' `run`
# so you can check the exit code and use `assert_`.
#
# @param ${1} = timeout
# @param ... = test command to run
function _run_until_success_or_timeout() {
local TIMEOUT=${1:?Timeout duration must be provided}
shift 1
if [[ ! ${TIMEOUT} =~ ^[0-9]+$ ]]
then
echo "First parameter for timeout must be an integer, received \"${TIMEOUT}\""
return 1
fi
local TIMEOUT=${1}
local STARTTIME=${SECONDS}
shift 1
until run "${@}" && [[ $status -eq 0 ]]
until run "${@}" && [[ ${status} -eq 0 ]]
do
sleep 1
if (( SECONDS - STARTTIME > TIMEOUT )); then
if (( SECONDS - STARTTIME > TIMEOUT ))
then
echo "Timed out on command: ${*}" >&2
return 1
fi
done
}
# @param ${1} timeout
# @param ${2} container name
# @param ... test command for container
function repeat_in_container_until_success_or_timeout() {
local TIMEOUT="${1}"
local CONTAINER_NAME="${2}"
shift 2
# ? << Functions about executing commands with timeouts
# ! -------------------------------------------------------------------
# ? >> Functions to wait until a condition is met
repeat_until_success_or_timeout --fatal-test "container_is_running ${CONTAINER_NAME}" "${TIMEOUT}" docker exec "${CONTAINER_NAME}" "${@}"
# Wait until a port is ready.
#
# @param ${1} = port
# @param ${2} = container name [OPTIONAL]
function _wait_for_tcp_port_in_container() {
local PORT=${1:?Port number must be provided}
local CONTAINER_NAME=$(__handle_container_name "${2:-}")
_repeat_until_success_or_timeout \
--fatal-test "_container_is_running ${CONTAINER_NAME}" \
"${TEST_TIMEOUT_IN_SECONDS}" \
_exec_in_container_bash "nc -z 0.0.0.0 ${PORT}"
}
function container_is_running() {
[[ "$(docker inspect -f '{{.State.Running}}' "${1}")" == "true" ]]
# Wait for SMTP port (25) to become ready.
#
# @param ${1} = name of the container [OPTIONAL]
function _wait_for_smtp_port_in_container() {
local CONTAINER_NAME=$(__handle_container_name "${1:-}")
_wait_for_tcp_port_in_container 25
}
# @param ${1} port
# @param ${2} container name
function wait_for_tcp_port_in_container() {
repeat_until_success_or_timeout --fatal-test "container_is_running ${2}" "${TEST_TIMEOUT_IN_SECONDS}" docker exec "${2}" /bin/sh -c "nc -z 0.0.0.0 ${1}"
}
# Wait until the SMPT port (25) can respond.
#
# @param ${1} = name of the container [OPTIONAL]
function _wait_for_smtp_port_in_container_to_respond() {
local CONTAINER_NAME=$(__handle_container_name "${1:-}")
# @param ${1} name of the postfix container
function wait_for_smtp_port_in_container() {
wait_for_tcp_port_in_container 25 "${1}"
}
# @param ${1} name of the postfix container
function wait_for_smtp_port_in_container_to_respond() {
local COUNT=0
until [[ $(docker exec "${1}" timeout 10 /bin/sh -c "echo QUIT | nc localhost 25") == *"221 2.0.0 Bye"* ]]; do
if [[ $COUNT -eq 20 ]]
until [[ $(_exec_in_container timeout 10 /bin/bash -c 'echo QUIT | nc localhost 25') == *'221 2.0.0 Bye'* ]]
do
if [[ ${COUNT} -eq 20 ]]
then
echo "Unable to receive a valid response from 'nc localhost 25' within 20 seconds"
return 1
fi
sleep 1
((COUNT+=1))
(( COUNT += 1 ))
done
}
# @param ${1} name of the postfix container
function wait_for_amavis_port_in_container() {
wait_for_tcp_port_in_container 10024 "${1}"
# Checks whether a service is running inside a container (${1}).
#
# @param ${1} = service name
# @param ${2} = container name [OPTIONAL]
function _should_have_service_running_in_container() {
local SERVICE_NAME="${1:?Service name must be provided}"
local CONTAINER_NAME=$(__handle_container_name "${2:-}")
_run_in_container /usr/bin/supervisorctl status "${SERVICE_NAME}"
assert_success
assert_output --partial 'RUNNING'
}
# get the private config path for the given container or test file, if no container name was given
function private_config_path() {
echo "${PWD}/test/duplicate_configs/${1:-$(basename "${BATS_TEST_FILENAME}")}"
}
# Wait until a service is running.
#
# @param ${1} = name of the service to wait for
# @param ${2} = container name [OPTIONAL]
function _wait_for_service() {
local SERVICE_NAME="${1:?Service name must be provided}"
local CONTAINER_NAME=$(__handle_container_name "${2:-}")
function container_has_service_running() {
local CONTAINER_NAME="${1}"
local SERVICE_NAME="${2}"
docker exec "${CONTAINER_NAME}" /usr/bin/supervisorctl status "${SERVICE_NAME}" | grep RUNNING >/dev/null
}
function wait_for_service() {
local CONTAINER_NAME="${1}"
local SERVICE_NAME="${2}"
repeat_until_success_or_timeout --fatal-test "container_is_running ${CONTAINER_NAME}" "${TEST_TIMEOUT_IN_SECONDS}" \
container_has_service_running "${CONTAINER_NAME}" "${SERVICE_NAME}"
}
# NOTE: Relies on ENV `LOG_LEVEL=debug` or higher
function _wait_until_expected_count_is_matched() {
function __get_count() {
# NOTE: `|| true` required due to `set -e` usage:
# https://github.com/docker-mailserver/docker-mailserver/pull/2997#discussion_r1070583876
docker exec "${CONTAINER_NAME}" grep --count "${MATCH_CONTENT}" "${MATCH_IN_LOG}" || true
}
# WARNING: Keep in mind it is a '>=' comparison.
# If you provide an explict count to match, ensure it is not too low to cause a false-positive.
function __has_expected_count() {
[[ $(__get_count) -ge "${EXPECTED_COUNT}" ]]
}
local CONTAINER_NAME=${1}
local EXPECTED_COUNT=${2}
# Ensure early failure if arg is missing:
assert_not_equal "${CONTAINER_NAME}" ''
# Ensure the container is configured with the required `LOG_LEVEL` ENV:
assert_regex \
$(docker exec "${CONTAINER_NAME}" env | grep '^LOG_LEVEL=') \
'=(debug|trace)$'
# Default behaviour is to wait until one new match is found (eg: incremented),
# unless explicitly set (useful for waiting on a min count to be reached):
if [[ -z $EXPECTED_COUNT ]]
then
# +1 of starting count:
EXPECTED_COUNT=$(( $(__get_count) + 1 ))
fi
repeat_until_success_or_timeout 20 __has_expected_count
_repeat_until_success_or_timeout \
--fatal-test "_container_is_running ${CONTAINER_NAME}" \
"${TEST_TIMEOUT_IN_SECONDS}" \
_should_have_service_running_in_container "${SERVICE_NAME}"
}
# An account added to `postfix-accounts.cf` must wait for the `changedetector` service
# to process the update before Dovecot creates the mail account and associated storage dir:
function wait_until_account_maildir_exists() {
local CONTAINER_NAME=$1
local MAIL_ACCOUNT=$2
# to process the update before Dovecot creates the mail account and associated storage dir.
#
# @param ${1} = mail account name
# @param ${2} = container name
function _wait_until_account_maildir_exists() {
local MAIL_ACCOUNT=${1:?Mail account must be provided}
local CONTAINER_NAME=$(__handle_container_name "${2:-}")
local LOCAL_PART="${MAIL_ACCOUNT%@*}"
local DOMAIN_PART="${MAIL_ACCOUNT#*@}"
local MAIL_ACCOUNT_STORAGE_DIR="/var/mail/${DOMAIN_PART}/${LOCAL_PART}"
repeat_in_container_until_success_or_timeout 60 "${CONTAINER_NAME}" bash -c "[[ -d ${MAIL_ACCOUNT_STORAGE_DIR} ]]"
_repeat_in_container_until_success_or_timeout 60 "${CONTAINER_NAME}" \
/bin/bash -c "[[ -d ${MAIL_ACCOUNT_STORAGE_DIR} ]]"
}
function add_mail_account_then_wait_until_ready() {
local CONTAINER_NAME=$1
local MAIL_ACCOUNT=$2
# Password is optional (omit when the password is not needed during the test)
local MAIL_PASS="${3:-password_not_relevant_to_test}"
run docker exec "${CONTAINER_NAME}" setup email add "${MAIL_ACCOUNT}" "${MAIL_PASS}"
assert_success
wait_until_account_maildir_exists "${CONTAINER_NAME}" "${MAIL_ACCOUNT}"
}
function wait_for_empty_mail_queue_in_container() {
local CONTAINER_NAME="${1}"
# Wait until the mail queue is empty inside a container (${1}).
#
# @param ${1} = container name [OPTIONAL]
function _wait_for_empty_mail_queue_in_container() {
local CONTAINER_NAME=$(__handle_container_name "${1:-}")
local TIMEOUT=${TEST_TIMEOUT_IN_SECONDS}
# shellcheck disable=SC2016
repeat_in_container_until_success_or_timeout "${TIMEOUT}" "${CONTAINER_NAME}" bash -c '[[ $(mailq) == *"Mail queue is empty"* ]]'
_repeat_in_container_until_success_or_timeout \
"${TIMEOUT}" \
"${CONTAINER_NAME}" \
/bin/bash -c '[[ $(mailq) == "Mail queue is empty" ]]'
}
# `lines` is a special BATS variable updated via `run`:
function _should_output_number_of_lines() {
assert_equal "${#lines[@]}" $1
# ? << Functions to wait until a condition is met
# ! -------------------------------------------------------------------
# ? >> Miscellaneous helper functions
# Adds a mail account and waits for the associated files to be created.
#
# @param ${1} = mail account name
# @param ${2} = password [OPTIONAL]
# @param ${3} = container name [OPTIONAL]
function _add_mail_account_then_wait_until_ready() {
local MAIL_ACCOUNT=${1:?Mail account must be provided}
local MAIL_PASS="${2:-password_not_relevant_to_test}"
local CONTAINER_NAME=$(__handle_container_name "${3:-}")
_run_in_container setup email add "${MAIL_ACCOUNT}" "${MAIL_PASS}"
assert_success
_wait_until_account_maildir_exists "${MAIL_ACCOUNT}"
}
# Assert that the number of lines output by a previous command matches the given
# amount (${1}). `lines` is a special BATS variable updated via `run`.
#
# @param ${1} = number of lines that the output should have
function _should_output_number_of_lines() {
assert_equal "${#lines[@]}" "${1:?Number of lines not provided}"
}
# Reloads the postfix service.
#
# @param ${1} = container name [OPTIONAL]
function _reload_postfix() {
local CONTAINER_NAME=$(__handle_container_name "${1:-}")
# Reloading Postfix config after modifying it within 2 seconds will cause Postfix to delay reading `main.cf`:
# WORKAROUND: https://github.com/docker-mailserver/docker-mailserver/pull/2998
_exec_in_container touch -d '2 seconds ago' /etc/postfix/main.cf
_exec_in_container postfix reload
}
# Get the IP of the container (${1}).
#
# @param ${1} = container name [OPTIONAL]
function _get_container_ip() {
local TARGET_CONTAINER_NAME=$(__handle_container_name "${1:-}")
docker inspect --format '{{ .NetworkSettings.IPAddress }}' "${TARGET_CONTAINER_NAME}"
}
# Check if a container is running.
#
# @param ${1} = container name [OPTIONAL]
function _container_is_running() {
local TARGET_CONTAINER_NAME=$(__handle_container_name "${1:-}")
[[ $(docker inspect -f '{{.State.Running}}' "${TARGET_CONTAINER_NAME}") == 'true' ]]
}
# Checks if the directory exists and then how many files it contains at the top-level.
#
# @param ${1} = directory
# @param ${2} = number of files that should be in ${1}
# @param ${3} = container name [OPTIONAL]
function _count_files_in_directory_in_container()
{
local DIRECTORY=${1:?No directory provided}
local NUMBER_OF_LINES=${2:?No line count provided}
local CONTAINER_NAME=$(__handle_container_name "${3:-}")
_run_in_container_bash "[[ -d ${DIRECTORY} ]]"
assert_success
_run_in_container_bash "find ${DIRECTORY} -maxdepth 1 -type f -printf 'x\n'"
assert_success
_should_output_number_of_lines "${NUMBER_OF_LINES}"
}
# ? << Miscellaneous helper functions
# ! -------------------------------------------------------------------