diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5cb28fb --- /dev/null +++ b/Makefile @@ -0,0 +1,542 @@ +# +# This Makefile is majorly working by convention. You just have to setup the +# variables in 'Makefile.vars' and structure your code according to the setup +# convention: +# +# 1. Place all commands as '.go'-files in the 'cmd'-directory. +# 2. Use the standard 'config' package to read configuration for all commands. +# 3. Use a common 'Dockerfile' to install all commands into. +# +# The Makefile also allows to call run the commands/services and run test via +# 'make run/test-* -- [args]', e.g. 'make test-unit app/service' runs all the +# unit tests in the directory 'app/service'. +# +# To support 'run-*' commands, you need to setup the environment variables for +# your designated runtime by defining the custom functions for setting it up +# via 'run-setup', 'run-vars', 'run-vars-local', and 'run-vars-image' in +# 'Makefile.defs'. Test are supposed to run with global defaults and should not +# need more setup. The setup strongly depends on the command, but usual there +# are common patterns in this that can be copied from other projects. +# +# To enable postgres database support you must add 'run-db' to TEST_DEPS and +# RUN_DEPS as needed to 'Makefile.vars'. You can also override the default +# setup via the DB_HOST, DB_PORT, DB_NAME, DB_USER, and DB_PASSWORD variables, +# but this is optional. Note: when running test against a DB you usually have +# to extend the default TEST_TIMEOUT of 10s to a more reasonable value. +# +# To enable AWS localstack support you have to add 'run-aws' to the TEST_DEPS +# and RUN_DEPS. You may also provide a sensible setup of AWS services via the +# AWS_SERVICES variable (default is 'sqs s3'). +# +# Note: You can discover make targets using the tab-completion of your shell. +# + +# === do not change this Makefile! extended via Makefile.{vars,defs,targets} === + +SHELL := /bin/bash + +RUNDIR := $(CURDIR)/run +BUILDIR := $(CURDIR)/build +CREDDIR := $(RUNDIR)/creds +TEMPDIR := $(RUNDIR)/temp + +TEST_ALL := $(BUILDIR)/test-all.cover +TEST_UNIT := $(BUILDIR)/test-unit.cover +LINT_ALL := lint-src lint-api + +# Include required custom variables. +ifneq ("$(wildcard Makefile.vars)","") + include Makefile.vars +else + $(error error: please define variables in Makefile.vars) +endif + +# Setup sensible defaults for configuration variables. +TEST_TIMEOUT ?= 10s + +CONTAINER ?= Dockerfile +REPOSITORY ?= $(shell git remote get-url origin | \ + sed "s/^https:\/\///; s/^git@//; s/.git$$//; s/:/\//;") +TEAM ?= $(shell cat .zappr.yaml | grep "X-Zalando-Team" | \ + sed "s/.*: *\([a-z-]*\).*/\1/") + + +IMAGE_PUSH ?= test +IMAGE_VERSION ?= snapshot + +ifeq ($(words $(subst /, ,$(IMAGE_NAME))),3) + IMAGE_HOST ?= $(wordlist 1,1,$(subst /, ,$(IMAGE_NAME))) + IMAGE_TEAM ?= $(wordlist 2,2,$(subst /, ,$(IMAGE_NAME))) + IMAGE_ARTIFACT ?= $(wordlist 3,3,$(subst /, ,$(IMAGE_NAME))) +else + IMAGE_HOST ?= pierone.stups.zalan.do + IMAGE_TEAM ?= $(TEAM) + IMAGE_ARTIFACT ?= $(wordlist 3,3,$(subst /, ,$(REPOSITORY))) +endif +IMAGE ?= $(IMAGE_HOST)/$(IMAGE_TEAM)/$(IMAGE_ARTIFACT):$(IMAGE_VERSION) + + +DB_HOST ?= 127.0.0.1 +DB_PORT ?= 5432 +DB_NAME ?= db +DB_USER ?= user +DB_PASSWORD ?= pass +DB_VERSION ?= latest +DB_IMAGE ?= postgres:$(DB_VERSION) + +AWS_SERVICES ?= sqs s3 +AWS_VERSION ?= latest +AWS_IMAGE ?= localstack/localstack:$(AWS_VERSION) + +# default target list for all and cdp builds. +TARGETS_ALL ?= init test lint build +TARGETS_CDP ?= clean clean-run init test lint \ + $(if $(filter $(IMAGE_PUSH),never),,\ + $(if $(wildcard $(CONTAINER)),image-push,)) +TARGETS_LINT ?= $(LINT_ALL) + + +# General setup of tokens for run-targets (not to be modified) + +# Often used token setup functions. +define run-token-create + mkdir -p $(CREDDIR); ztoken > $(CREDDIR)/token; echo "Bearer" > $(CREDDIR)/type +endef +define run-token-link + test -n "$(1)" && test -n "$(2)" && test -n "$(3)" && ( \ + test -h "$(CREDDIR)/$(1)-$(2)" || ln -s type "$(CREDDIR)/$(1)-$(2)" && \ + test -h "$(CREDDIR)/$(1)-$(3)" || ln -s token "$(CREDDIR)/$(1)-$(3)" \ + ) || test -n "$(1)" && test -n "$(2)" && test -z "$(3)" && ( \ + test -h "$(CREDDIR)/$(1)" || ln -s type "$(CREDDIR)/$(1)" && \ + test -h "$(CREDDIR)/$(2)" || ln -s token "$(CREDDIR)/$(2)" \ + ) || test -n "$(1)" && test -z "$(2)" && test -z "$(3)" && ( \ + test -h "$(CREDDIR)/$(1)" || ln -s token "$(CREDDIR)/$(1)" \ + ) || true +endef + +# Stub definition for general setup in run-targets. +define run-setup + true +endef + +# Stub definition for common variables in run-targets. +define run-vars +endef + +# Stub definition for local runtime variables in run-targets. +define run-vars-local +endef + +# Stub definition for container specific runtime variables in run-targets. +define run-vars-image + $(call run-vars-docker) +endef + +# Stub definition to setup aws localstack run-target. +define run-aws-setup + true +endef + +# Include function definitions to override defaults. +ifneq ("$(wildcard Makefile.defs)","") + include Makefile.defs +else + $(warning warning: please define custom functions in Makefile.defs) +endif + + +# Setup default environment variables. +COMMANDS := $(shell grep -lr "func main()" cmd/*/main.go 2>/dev/null | \ + sed -E "s/^cmd\/([^/]*)\/main.go$$/\1/;" | sort -u) +SOURCES := $(shell find . -name "*.go" ! -name "mock_*_test.go") + +# Setup golang mock setup environment. +MOCK_MATCH_DST := ^.\/(.*)\/(.*):\/\/go:generate.*-destination=([^ ]*).*$$ +MOCK_MATCH_SRC := ^.\/(.*)\/(.*):\/\/go:generate.*-source=([^ ]*).*$$ +MOCK_TARGETS := $(shell grep "//go:generate.*mockgen" $(SOURCES) | \ + sed -E "s/$(MOCK_MATCH_DST)/\1\/\3=\1\/\2/;" | sort -u) +MOCK_SOURCES := $(shell grep "//go:generate.*mockgen.*-source" $(SOURCES) | \ + sed -E "s/$(MOCK_MATCH_SRC)/\1\/\3/;" | sort -u | \ + xargs realpath --relative-base=.) +MOCKS := $(shell for TARGET in $(MOCK_TARGETS); \ + do echo "$${TARGET%%=*}"; done | sort -u) + + +# Setup phony make targets to always be executed. +.PHONY: all cdp bump release +.PHONY: update update-go update-deps update-make +.PHONY: clean clean-init clean-build clean-run +.PHONY: $(addprefix clean-run-, $(COMMANDS) db aws) +.PHONY: init init-tools init-packages init-sources +.PHONY: test test-all test-unit test-clean test-upload test-cover +.PHONY: lint lint-src lint-api format +.PHONY: build build-native build-linux build-image build-docker +.PHONY: $(addprefix build-, $(COMMANDS)) +.PHONY: image image-build image-push docker docker-build docker-push +.PHONY: run run-all run-native run-image run-docker run-clean +.PHONY: $(addprefix run-, $(COMMANDS) db aws) +.PHONY: $(addprefix run-lang-, $(COMMANDS)) +.PHONY: $(addprefix run-image-, $(COMMANDS)) +.PHONY: $(addprefix run-docker-, $(COMMANDS)) +.PHONY: $(addprefix run-clean-, $(COMMANDS) db aws) + + +# Setup docker or podman command. +IMAGE_CMD ?= $(shell command -v podman || command -v docker) +ifndef IMAGE_CMD + $(error error: docker/podman command not found) +endif + + +# Helper definitions to resolve match position of word in text. +define pos-recursion + $(if $(findstring $(1),$(2)),$(call pos-recursion,$(1),\ + $(wordlist 2,$(words $(2)),$(2)),x $(3)),$(3)) +endef +define pos + $(words $(call pos-recursion,$(1),$(2))) +endef + + +# match commands that support arguments ... +CMDMATCH = $(or \ + $(findstring run-,$(MAKECMDGOALS)),\ + $(findstring test-,$(MAKECMDGOALS)),\ + $(findstring bump,$(MAKECMDGOALS)),\ + )% +# If any argument contains "run-", "test-", "bump" ... +ifneq ($(CMDMATCH),%) + CMD := $(filter $(CMDMATCH),$(MAKECMDGOALS)) + POS = $(call pos,$(CMD), $(MAKECMDGOALS)) + # ... then use the rest as arguments for "run/test-" ... + CMDARGS := $(wordlist $(shell expr $(POS) + 1),\ + $(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) + # ...and turn them into do-nothing targets. + $(eval $(CMDARGS):;@:) + RUNARGS ?= $(CMDARGS) + $(info translated targets to arguments (ARGS=[$(RUNARGS)])) +endif + + +# Initialize golang modules - if not done before. +ifneq ($(shell ls go.mod), go.mod) + $(shell go mod init $(REPOSITORY)) +endif + +# Setup go to use desired and consistent golang versions. +GOVERSION := $(shell go version | sed -Ee "s/.*go([0-9]+\.[0-9]+).*/\1/") +GOVERSION_MOD := $(shell grep "^go [1-9.]*$$" go.mod | cut -f2 -d' ') +GOVERSION_DELIVERY := $(shell if [ -f delivery.yaml ]; then \ + grep -o "cdp-runtime/go-[0-9.]*" delivery.yaml | grep -o "[0-9.]*" | sort -u; \ + else echo $(GOVERSION); fi) +ifneq (update-go,$(MAKECMDGOALS)) + ifneq ($(firstword $(GOVERSION_DELIVERY)), $(GOVERSION_DELIVERY)) + $(error "inconsistent go versions: delivery.yaml uses $(GOVERSION_DELIVERY)") + endif + ifneq ($(GOVERSION), $(GOVERSION_DELIVERY)) + $(error "unsupported go version $(GOVERSION): delivery.yaml requires $(GOVERSION_DELIVERY)") + endif + ifneq ($(GOVERSION), $(GOVERSION_MOD)) + $(error "unsupported go version $(GOVERSION): go.mod requires $(GOVERSION_MOD)") + endif +endif + +# Export private repositories not to be downloaded. +export GOPRIVATE := github.bus.zalan.do + + +# Standard targets for default build processes. +all: $(TARGETS_ALL) +cdp: $(TARGETS_CDP) + + +# Update dependencies of all packages. +update: update-deps +update-go: + @sed -i "s/go $(GOVERSION_MOD)/go $(GOVERSION)/" go.mod; \ + sed -E -i "s/(cdp-runtime\/go)[0-9.-]*/\1-$(GOVERSION)/" delivery.yaml; \ + +update-make: + @echo "update Makefile"; TEMPDIR=$$(mktemp -d); \ + BASEREPO=git@github.bus.zalan.do:builder-knowledge/go-base.git; ( \ + git clone --no-checkout --depth 1 $${BASEREPO} $${TEMPDIR} 2>/dev/null && \ + (cd $${TEMPDIR} && git show HEAD:Makefile > Makefile; cd -) \ + ); rm -rf $${DIRTEMP}; \ + +update-make-would-be-better: + BASEREPO=git://github.bus.zalan.do/builder-knowledge/go-base.git; \ + git archive --remote=$${BASEREPO} HEAD Makefile | tar -xvf -; \ + +update-deps: + @for DIR in $$(find . -name "*.go" | xargs dirname | sort -u); do \ + echo -n "update: $${DIR} -> "; cd $${DIR} && \ + go get -u && go mod tidy -compat=${GOVERSION} && \ + cd -; \ + done; \ + +# Bump version to prepare release of software. +bump: + @if [ -z "$(RUNARGS)" ]; then \ + echo "error: missing new version"; exit 1; \ + fi; \ + VERSION="$$(echo $(RUNARGS) | \ + grep -E -o "[0-9]+\.[0-9]+\.[0-9]+(-.*)?")"; \ + if [ -z "$${VERSION}" ]; then \ + echo "error: invalid new version [$(RUNARGS)]"; exit 1; \ + fi; \ + echo "$${VERSION}" > VERSION; \ + echo "Bumped version to $${VERSION} for auto release!"; \ + +# Release fixed version of software. +release: + @VERSION="$$(cat VERSION)" && \ + if [ -n "$${VERSION}" -a \ + -z "$$(git tag -l "v$${VERSION}")" ]; then \ + git gh-release "v$${VERSION}" && \ + echo "Added release tag v$${VERSION} to repository!"; \ + fi; \ + + +# Default cleanup of sources. +clean: clean-build +clean-build: clean-init + find . -name "mock_*_test.go" -exec rm -v {} \;; \ + +clean-init: + @echo go version $(GOVERSION); + rm -vrf $(RUNDIR) $(BUILDIR); + +# Clean up all running container images. +clean-run: $(addprefix clean-run-, $(COMMANDS) db aws) +$(addprefix clean-run-, $(COMMANDS) db aws): clean-run-%: run-clean-% + + +# Initialize tooling and packages for building. +init: clean-init init-tools init-packages + +init-tools: + go install github.com/golang/mock/mockgen@latest; + go install golang.org/x/lint/golint@latest; + go install github.com/zalando/zally/cli/zally@latest; + go install golang.org/x/tools/cmd/goimports@latest; + go mod tidy -compat=${GOVERSION}; + +init-packages: + go build ./...; + +init-sources: $(MOCKS) +$(MOCKS): go.sum $(MOCK_SOURCES) + GO111MODULE=on go generate "$(shell echo $(MOCK_TARGETS) | \ + sed -E "s:.*$@=([^ ]*).*$$:\1:;")"; + + +test: test-all +test-all: test-clean $(MOCKS) $(TEST_ALL) +test-unit: test-clean $(MOCKS) $(TEST_UNIT) +test-clean: + @if [ -f "$(TEST_ALL)" ]; then rm -v $(TEST_ALL); fi; \ + if [ -f "$(TEST_UNIT)" ]; then rm -v $(TEST_UNIT); fi; +test-upload: +test-cover: + @if [ "$(TEST_ALL)" -nt "$(TEST_UNIT)" ]; then \ + go tool cover -html=$(TEST_ALL); \ + else \ + go tool cover -html=$(TEST_UNIT); \ + fi; \ + +TESTFLAGS ?= -race -mod=readonly -count=1 +ifneq ($(RUNARGS),) + TESTARGS ?= $(addprefix ./,$(RUNARGS)) +else + TESTARGS ?= ./... +endif + +$(TEST_ALL): $(SOURCES) $(MOCKS) $(TEST_DEPS) + @if [ ! -d "$(BUILDIR)" ]; then mkdir -p $(BUILDIR); fi; + go test $(TESTFLAGS) -timeout $(TEST_TIMEOUT) \ + -cover -coverprofile $@ $(TESTARGS); +$(TEST_UNIT): $(SOURCES) $(MOCKS) + @if [ ! -d "$(BUILDIR)" ]; then mkdir -p $(BUILDIR); fi; + go test $(TESTFLAGS) -timeout $(TEST_TIMEOUT) \ + -cover -coverprofile $@ -short $(TESTARGS); + + +lint: $(TARGETS_LINT) +lint-src: + go vet $$(go list ./...); + golint -set_exit_status $$(go list ./...); + @GOIMPORT="$$(goimports -l -local "$(REPOSITORY)" \ + $$(find . -name "*.go" ! -name "mock_*_test.go"))"; \ + if [ -n "$${GOIMPORT}" ]; then \ + echo -e "Error: Sources are not formatted correctly:"; \ + echo -e "$${GOIMPORT}\n\tTo fix run: make format"; false; \ + fi; +lint-api: + @ARGS=("--linter-service" "https://infrastructure-api-linter.zalandoapis.com"); \ + if command -v ztoken > /dev/null; then ARGS+=("--token" "$$(ztoken)"); fi; \ + for APISPEC in $$(find zalando-apis -name "*.yaml" > /dev/null 2>&1); do \ + echo "check API: zally \"$${APISPEC}\""; \ + zally "$${ARGS[@]}" lint "$${APISPEC}" || exit 1; \ + done; + +format: + goimports -w -local "$(REPOSITORY)" $$(find . -name "*.go" ! -name "mock_*_test.go") + + +# Setup container specific build flags +BUILDOS ?= ${shell grep "^FROM [^ ]*$$" $(CONTAINER) | grep -v " as " | \ + sed -e "s/.*\(alpine\|ubuntu\).*/\1/g"} +BUILDARCH ?= amd64 +ifeq ($(BUILDOS),alpine) + BUILDFLAGS ?= -v -mod=readonly + GOCGO := 0 +else + BUILDFLAGS ?= -v -race -mod=readonly + GOCGO := 1 +endif +GOOS ?= linux +GOARCH := $(BUILDARCH) + +# Define flags propagate versions to build commands. +LDFLAGS ?= -X $(shell go list ./... | grep "config$$").Version=$(IMAGE_VERSION) \ + -X $(shell go list ./... | grep "config$$").GitHash=$(shell git rev-parse --short HEAD) + +# Build targets for native platform builds and linux builds. +build: build-native +build-image: image-build +build-native: $(addprefix build/, $(COMMANDS)) +$(addprefix build-, $(COMMANDS)): build-%: build/% +build/%: cmd/%/main.go $(SOURCES) + @mkdir -p "$(dir $@)" + CGO_ENABLED=1 go build \ + $(BUILDFLAGS) -ldflags="$(LDFLAGS)" -o $@ $<; + +build-linux: $(addprefix $(BUILDIR)/linux/, $(COMMANDS)) +$(BUILDIR)/linux/%: cmd/%/main.go $(SOURCES) + @mkdir -p $(dir $@) + GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=$(GOCGO) go build \ + $(BUILDFLAGS) -ldflags="$(LDFLAGS)" -o $@ $<; + + +# Image build and push targets. +image: image-build +image-build: $(CONTAINER) build-linux + @if [ "$(IMAGE_PUSH)" == "never" ]; then \ + echo "We never build images, aborting."; exit 0; \ + else \ + $(IMAGE_CMD) build -t $(IMAGE) -f $< .; \ + fi; \ + +image-push: image-build + @if [ "$(IMAGE_PUSH)" == "never" ]; then \ + echo "We never push images, aborting."; exit 0; \ + elif [ "$(IMAGE_VERSION)" == "snapshot" ]; then \ + echo "We never push snapshot images, aborting."; exit 0; \ + elif [ -n "$(CDP_PULL_REQUEST_NUMBER)" -a "$(IMAGE_PUSH)" != "test" ]; then \ + echo "We never push pull request images, aborting."; exit 0; \ + fi; \ + $(IMAGE_CMD) push $(IMAGE); \ + + +# Target for running a postgres database. +run-db: + @if [[ ! "$(TEST_DEPS) $(RUN_DEPS)" =~ run-db ]]; then exit 0; fi; \ + mkdir -p $(RUNDIR) && HOST="127.0.0.1" && \ + echo "info: ensure $(DB_IMAGE) running on $${HOST}:$(DB_PORT)"; \ + if [ -n "$$($(IMAGE_CMD) ps | grep "$(DB_IMAGE).*$${HOST}:$(DB_PORT)")" ]; then \ + echo "warning: port allocated, try existing db container!"; exit 0; \ + fi; \ + $(IMAGE_CMD) start ${IMAGE_ARTIFACT}-db 2>/dev/null || ( \ + $(IMAGE_CMD) run -dt \ + --name ${IMAGE_ARTIFACT}-db \ + --publish $${HOST}:$(DB_PORT):5432 \ + --env POSTGRES_USER="$(DB_USER)" \ + --env POSTGRES_PASSWORD="$(DB_PASSWORD)" \ + --env POSTGRES_DB="$(DB_NAME)" \ + $(DB_IMAGE) $(RUNARGS) 2>&1 & \ + until [ "$$($(IMAGE_CMD) inspect -f {{.State.Running}} \ + $(IMAGE_ARTIFACT)-db 2>/dev/null)" == "true" ]; \ + do echo "waiting for db container" >/dev/stderr; sleep 1; done && \ + until $(IMAGE_CMD) exec $(IMAGE_ARTIFACT)-db \ + pg_isready -h localhost -U $(DB_USER) -d $(DB_NAME); \ + do echo "waiting for db service" >/dev/stderr; sleep 1; done) |\ + tee -a $(RUNDIR)/$(IMAGE_ARTIFACT)-db; \ + +# Target for running the AWS localstack. +run-aws: + @if [[ ! "$(TEST_DEPS) $(RUN_DEPS)" =~ run-aws ]]; then exit 0; fi; \ + mkdir -p $(RUNDIR) && HOST="127.0.0.1" && \ + echo "info: ensure $(AWS_IMAGE) is running on $${HOST}:4566/4571" && \ + if [ -n "$$($(IMAGE_CMD) ps | \ + grep "$(AWS_IMAGE).*$${HOST}:4566.*$${HOST}:4571")" ]; then \ + echo "warning: ports allocated, try existing aws container!"; \ + $(call run-aws-setup); exit 0; \ + fi; \ + $(IMAGE_CMD) start ${IMAGE_ARTIFACT}-aws 2>/dev/null || ( \ + $(IMAGE_CMD) run -dt --name ${IMAGE_ARTIFACT}-aws \ + --publish $${HOST}:4566:4566 \ + --publish $${HOST}:4571:4571 \ + --env SERVICES="$(AWS_SERVICES)" \ + $(AWS_IMAGE) $(RUNARGS) 2>&1 && \ + until [ "$$($(IMAGE_CMD) inspect -f {{.State.Running}} \ + $(IMAGE_ARTIFACT)-aws 2>/dev/null)" == "true" ]; \ + do echo "waiting for aws container" >/dev/stderr; sleep 1; done && \ + until [ -n "$$($(IMAGE_CMD) exec $(IMAGE_ARTIFACT)-aws \ + curl -is http://$${HOST}:4566 | grep -o "HTTP/1.1 200")" ]; \ + do echo "waiting for aws service" >/dev/stderr; sleep 1; done && \ + $(call run-aws-setup)) | \ + tee -a $(RUNDIR)/$(IMAGE_ARTIFACT)-aws.log; \ + +# Targets for running the provide commands natively. +$(addprefix run-, $(COMMANDS)): run-%: build/% $(RUN_DEPS) + @mkdir -p $(RUNDIR) && $(call run-setup); + $(call run-vars) $(call run-vars-local) \ + $(BUILDIR)/$* $(RUNARGS) 2>&1 | \ + tee -a $(RUNDIR)/$(IMAGE_ARTIFACT)-$*.log; \ + exit $${PIPESTATUS[0]}; + +# Targets for running the provide commands via golang. +$(addprefix run-lang-, $(COMMANDS)): run-lang-%: $(BUILDIR)/% $(RUN_DEPS) + @mkdir -p $(RUNDIR) && $(call run-setup); + $(call run-vars) $(call run-vars-local) \ + go run cmd/$*/main.go $(RUNARGS) 2>&1 | \ + tee -a $(RUNDIR)/$(IMAGE_ARTIFACT)-$*.log; \ + exit $${PIPESTATUS[0]}; + +# Target to run commands in container images. +$(addprefix run-image-, $(COMMANDS)): run-image-%: $(RUN_DEPS) + @mkdir -p $(RUNDIR) && $(call run-setup); \ + trap "$(IMAGE_CMD) rm $(IMAGE_ARTIFACT)-$* >/dev/null" EXIT; \ + trap "$(IMAGE_CMD) kill $(IMAGE_ARTIFACT)-$* >/dev/null" INT TERM; + $(IMAGE_CMD) run --name $(IMAGE_ARTIFACT)-$* --network=host \ + --volume $(CREDDIR):/meta/credentials --volume $(RUNDIR)/temp:/tmp \ + $(call run-vars, --env) $(call run-vars-image, --env) \ + $(IMAGE) /$* $(RUNARGS) 2>&1 | \ + tee -a $(RUNDIR)/$(IMAGE_ARTIFACT)-$*.log; \ + exit $${PIPESTATUS[0]}; + +# Clean up all running container images. +run-clean: $(addprefix run-clean-, $(COMMANDS) db aws) +$(addprefix run-clean-, $(COMMANDS) db aws): run-clean-%: + @echo "check container $(IMAGE_ARTIFACT)-$*"; \ + if [ -n "$$($(IMAGE_CMD) ps | grep "$(IMAGE_ARTIFACT)-$*")" ]; then \ + $(IMAGE_CMD) kill $(IMAGE_ARTIFACT)-$* > /dev/null && \ + echo "killed container $(IMAGE_ARTIFACT)-$*"; \ + fi; \ + if [ -n "$$($(IMAGE_CMD) ps -a | grep "$(IMAGE_ARTIFACT)-$*")" ]; then \ + $(IMAGE_CMD) rm $(IMAGE_ARTIFACT)-$* > /dev/null && \ + echo "removed container $(IMAGE_ARTIFACT)-$*"; \ + fi; \ + + +# Deprected docker targets - to be removed. +docker: image +docker-build: image-build +docker-push: image-push +$(addprefix run-docker-, $(COMMANDS)): run-docker-%: run-image-% +build-docker: build-image + +# Include custom targets to extend scripts. +ifneq ("$(wildcard Makefile.targets)","") + include Makefile.targets +endif diff --git a/Makefile.defs b/Makefile.defs new file mode 100644 index 0000000..8ac3ad0 --- /dev/null +++ b/Makefile.defs @@ -0,0 +1,24 @@ +# Setup environment for all run-targets (to be modified) +define run-setup + true +endef + +# Define variables for all run-targets (to be modified) +define run-vars + +endef + +# Define variables for local run-targets (to be modified) +define run-vars-local + +endef + +# Define variables for image run-targets (to be modified) +define run-vars-image + +endef + +# Define aws localstack setup (to be modified). +define run-aws-setup + true +endef diff --git a/Makefile.vars b/Makefile.vars new file mode 100644 index 0000000..32e2878 --- /dev/null +++ b/Makefile.vars @@ -0,0 +1,7 @@ +# Setup required targets before testing. +#TEST_DEPS := run-db +# Setup required targets before running commands. +#RUN_DEPS := run-db + +IMAGE_PUSH ?= never +TEST_TIMEOUT := 5s diff --git a/README.md b/README.md new file mode 100644 index 0000000..0843032 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# Usage patterns of go-base/mock + +This package contains a small extension library to handle mock call setup in a +standardized way. Unfortunately, we had to sacrifice type-safety to allow for +chaining mock calls in an arbitrary way during setup. Anyhow, in tests runtime +validation is likely a sufficient strategy. + + +## Generic mock setup pattern + +To setup a generic mock handler for any number of mocks, one can simply use the +following template to setup an arbitrary system under test. + +```go +func SetupTestUnit( + t *gomock.TestReporter, + mockSetup mock.SetupFunc, + ... +) (*Unit, *Mocks) { + mocks := mock.NewMock(t).Expect(mockSetup) + + unit := NewUnitService( + mock.Get(mocks, NewServiceMock) + ).(*Unit) + + return unit, mocks +} +``` + +**Note:** The `mock.Get(mocks, NewServiceMock)` is the standard pattern to +request an existing or new mock instance from the mock handler. + + +## Generic mock service call pattern + +Now we need to define the mock service calls that follow a primitive, common +coding and naming pattern, that may be supported by code generation in the +future. + +```go +func ServiceCall(input..., output..., error) mock.SetupFunc { + return func(mocks *Mocks) any { + mocks.WaitGroup().Add(1) + return Get(mocks, NewServiceMock).EXPECT(). + ServiceCall(input...).Return(output..., error).Times(1). + Do(func(input... interface{}) { + defer mocks.WaitGroup().Done() + }) + + } +} +``` + +For simplicity the pattern combines regular as well as error behavior and is +prepared to handle tests with detached *goroutines*, however, this code can +be left out for further simplification. + +For detached *goroutines*, i.e. functions that do not communicate with the +test, the mock handler provides a `WaitGroup` to registers expected mock calls +using `mocks.WaitGroup().Add()` and notifying the occurrence by calling +`mocks.WaitGroup().Done()` in as a mock callback function registered for the +match via `Do()`. The test waits for the detached *goroutines* to finish +by calling `mocks.WaitGroup().Wait()`. + +A static series of mock service calls can now simply expressed by chaining the +mock service calls as follows using `mock.Chain` and while defining a new mock +call setup function: + +```go +func ServiceCallChain(input..., output..., error) mock.SetupFunc { + return func(mocks *Mocks) any { + return mock.Chain( + ServiceCallA(input...), + ServiceCallB(input...), + ... + } +} +``` + + +## Generic mock ordering patterns + +With the above preparations for mocking service calls we can now define the +*mock setup* easily using the following ordering methods: + +* `Chain` allows to create an ordered chain of mock calls that can be combined + with other setup methods that defermine the predecessors and successor mock + calls. + +* `Parallel` allows to creates an unordered set of mock calls that can be + combined with other setup methods that determine the predecessor and + successor mock calls. + +* `Setup` allows to create an unordered detached set of mock calls that creates + no relation to predecessors and successors it was defined with. + +Beside this simple (un-)ordering methods there are two further methods for +completeness, that allow to control how predecessors and successors are used +to setup ordering conditions: + +* `Sub` allows to define a sub-set or sub-chain of elements in `Parallel` and + `Chain` as predecessor and successor context for further combination. + +* `Detach` allows to detach an element from the predecessor context (`Head`), + from the successor context (`Tail`), or from both which is used in `Setup`. + +The application of these two functions may be a bit more complex but still +follows the intuition. + + +## Generic parameterized test pattern + +The ordering methods and the mock service call setups can now be used to define +the mock call expectations, in a parameter setup as follows to show the most +common use cases: + +```go +var testUnitCallParams = map[string]struct { + mockSetup mock.SetupFunc + ... + expect* *model.* + expectError error +}{ + "single mock setup": { + mockSetup: ServiceCall(...), + } + "chain mock setup": { + mockSetup: mock.Chain( + ServiceCallA(...), + ServiceCallB(...), + ... + ) + } + "nested chain mock setup": { + mockSetup: mock.Chain( + ServiceCallA(...), + mock.Chain( + ServiceCallA(...), + ServiceCallB(...), + ... + ), + ServiceCallB(...), + ... + ) + } + "parallel chain mock setup": { + mockSetup: mock.Parallel( + ServiceCallA(...), + mock.Chain( + ServiceCallB(...), + ServiceCallC(...), + ... + ), + mock.Chain( + ServiceCallD(...), + ServiceCallE(...), + ... + ), + ... + ) + } + ... +} +``` + +This test parameter setup can now be use for a parameterized unit test using +the following common pattern: + +```go +func TestUnitCall(t *testing.T) { + for message, param := range testUnitCallParams { + t.Run(message, func(t *testing.T) { + require.NotEmpty(t, message) + + //Given + unit, mocks := SetupTestUnit(t, param.mockSetup) + + //When + result, err := unit.UnitCall(...) + + mock.WaitGroup().Wait() + + //Then + if param.expectError != nil { + assert.Equal(t, param.expectError, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, param.expect*, result) + }) + } +} +``` diff --git a/build/test-unit.cover b/build/test-unit.cover new file mode 100644 index 0000000..d204581 --- /dev/null +++ b/build/test-unit.cover @@ -0,0 +1,99 @@ +mode: atomic +github.com/tkrop/testing/mock/mock.go:29.37,30.11 1 8 +github.com/tkrop/testing/mock/mock.go:31.12,32.16 1 0 +github.com/tkrop/testing/mock/mock.go:33.12,34.16 1 2 +github.com/tkrop/testing/mock/mock.go:35.12,36.16 1 2 +github.com/tkrop/testing/mock/mock.go:37.12,38.16 1 2 +github.com/tkrop/testing/mock/mock.go:39.10,40.19 1 2 +github.com/tkrop/testing/mock/mock.go:78.44,84.2 1 863 +github.com/tkrop/testing/mock/mock.go:87.54,88.20 1 863 +github.com/tkrop/testing/mock/mock.go:91.2,91.14 1 855 +github.com/tkrop/testing/mock/mock.go:88.20,90.3 1 863 +github.com/tkrop/testing/mock/mock.go:96.49,98.2 1 5513 +github.com/tkrop/testing/mock/mock.go:102.64,105.24 3 5752 +github.com/tkrop/testing/mock/mock.go:108.2,110.19 3 862 +github.com/tkrop/testing/mock/mock.go:105.24,107.3 1 4890 +github.com/tkrop/testing/mock/mock.go:118.55,119.27 1 975 +github.com/tkrop/testing/mock/mock.go:119.27,120.30 1 975 +github.com/tkrop/testing/mock/mock.go:123.3,123.13 1 966 +github.com/tkrop/testing/mock/mock.go:120.30,122.4 1 1071 +github.com/tkrop/testing/mock/mock.go:131.57,132.27 1 1639 +github.com/tkrop/testing/mock/mock.go:132.27,134.34 2 1639 +github.com/tkrop/testing/mock/mock.go:137.3,137.15 1 1638 +github.com/tkrop/testing/mock/mock.go:134.34,136.4 1 4123 +github.com/tkrop/testing/mock/mock.go:150.60,151.27 1 1456 +github.com/tkrop/testing/mock/mock.go:151.27,153.34 2 1456 +github.com/tkrop/testing/mock/mock.go:156.3,156.15 1 1456 +github.com/tkrop/testing/mock/mock.go:153.34,155.4 1 2911 +github.com/tkrop/testing/mock/mock.go:163.71,164.27 1 100 +github.com/tkrop/testing/mock/mock.go:164.27,165.15 1 100 +github.com/tkrop/testing/mock/mock.go:166.13,167.23 1 24 +github.com/tkrop/testing/mock/mock.go:168.13,169.37 1 25 +github.com/tkrop/testing/mock/mock.go:170.13,171.37 1 25 +github.com/tkrop/testing/mock/mock.go:172.13,173.37 1 25 +github.com/tkrop/testing/mock/mock.go:174.11,175.30 1 1 +github.com/tkrop/testing/mock/mock.go:185.65,186.27 1 64 +github.com/tkrop/testing/mock/mock.go:186.27,188.37 2 64 +github.com/tkrop/testing/mock/mock.go:189.14,191.46 2 15 +github.com/tkrop/testing/mock/mock.go:192.16,194.39 2 15 +github.com/tkrop/testing/mock/mock.go:195.19,197.39 2 15 +github.com/tkrop/testing/mock/mock.go:198.21,199.36 1 1 +github.com/tkrop/testing/mock/mock.go:200.21,201.36 1 1 +github.com/tkrop/testing/mock/mock.go:202.21,203.36 1 1 +github.com/tkrop/testing/mock/mock.go:204.12,205.14 1 15 +github.com/tkrop/testing/mock/mock.go:206.11,207.27 1 1 +github.com/tkrop/testing/mock/mock.go:216.54,219.15 3 53 +github.com/tkrop/testing/mock/mock.go:224.2,224.20 1 33 +github.com/tkrop/testing/mock/mock.go:219.15,221.3 1 1 +github.com/tkrop/testing/mock/mock.go:221.8,221.22 1 52 +github.com/tkrop/testing/mock/mock.go:221.22,223.3 1 19 +github.com/tkrop/testing/mock/mock.go:229.44,231.13 2 106 +github.com/tkrop/testing/mock/mock.go:240.2,240.16 1 1 +github.com/tkrop/testing/mock/mock.go:231.13,233.14 2 22 +github.com/tkrop/testing/mock/mock.go:236.3,236.13 1 21 +github.com/tkrop/testing/mock/mock.go:233.14,235.4 1 1 +github.com/tkrop/testing/mock/mock.go:237.8,237.22 1 84 +github.com/tkrop/testing/mock/mock.go:237.22,239.3 1 83 +github.com/tkrop/testing/mock/mock.go:247.53,248.28 1 4123 +github.com/tkrop/testing/mock/mock.go:267.2,267.14 1 4122 +github.com/tkrop/testing/mock/mock.go:248.28,249.35 1 4123 +github.com/tkrop/testing/mock/mock.go:250.14,251.31 1 3252 +github.com/tkrop/testing/mock/mock.go:252.16,253.34 1 24 +github.com/tkrop/testing/mock/mock.go:254.19,255.31 1 735 +github.com/tkrop/testing/mock/mock.go:256.21,257.31 1 24 +github.com/tkrop/testing/mock/mock.go:258.21,259.31 1 24 +github.com/tkrop/testing/mock/mock.go:260.21,261.31 1 24 +github.com/tkrop/testing/mock/mock.go:262.12,262.12 0 39 +github.com/tkrop/testing/mock/mock.go:263.11,264.26 1 1 +github.com/tkrop/testing/mock/mock.go:273.49,274.34 1 9245 +github.com/tkrop/testing/mock/mock.go:275.13,276.36 1 4950 +github.com/tkrop/testing/mock/mock.go:277.18,278.40 1 1471 +github.com/tkrop/testing/mock/mock.go:279.15,280.37 1 1614 +github.com/tkrop/testing/mock/mock.go:281.20,282.42 1 1088 +github.com/tkrop/testing/mock/mock.go:283.20,284.42 1 24 +github.com/tkrop/testing/mock/mock.go:285.20,286.42 1 24 +github.com/tkrop/testing/mock/mock.go:287.11,288.17 1 72 +github.com/tkrop/testing/mock/mock.go:289.10,290.25 1 2 +github.com/tkrop/testing/mock/mock.go:296.55,297.23 1 4950 +github.com/tkrop/testing/mock/mock.go:304.2,304.22 1 4950 +github.com/tkrop/testing/mock/mock.go:297.23,298.34 1 3852 +github.com/tkrop/testing/mock/mock.go:298.34,299.22 1 5331 +github.com/tkrop/testing/mock/mock.go:299.22,301.5 1 5331 +github.com/tkrop/testing/mock/mock.go:309.59,310.29 1 1614 +github.com/tkrop/testing/mock/mock.go:313.2,313.16 1 1614 +github.com/tkrop/testing/mock/mock.go:310.29,312.3 1 4059 +github.com/tkrop/testing/mock/mock.go:319.65,321.29 2 1471 +github.com/tkrop/testing/mock/mock.go:324.2,324.17 1 1470 +github.com/tkrop/testing/mock/mock.go:321.29,323.3 1 2941 +github.com/tkrop/testing/mock/mock.go:329.69,330.29 1 1088 +github.com/tkrop/testing/mock/mock.go:333.2,333.16 1 1086 +github.com/tkrop/testing/mock/mock.go:330.29,332.3 1 1088 +github.com/tkrop/testing/mock/mock.go:339.69,340.29 1 24 +github.com/tkrop/testing/mock/mock.go:343.2,343.16 1 24 +github.com/tkrop/testing/mock/mock.go:340.29,342.3 1 24 +github.com/tkrop/testing/mock/mock.go:349.69,350.29 1 24 +github.com/tkrop/testing/mock/mock.go:353.2,353.16 1 24 +github.com/tkrop/testing/mock/mock.go:350.29,352.3 1 24 +github.com/tkrop/testing/mock/mock.go:358.32,361.2 1 8 +github.com/tkrop/testing/mock/mock.go:364.43,366.2 1 2 +github.com/tkrop/testing/mock/mock.go:369.49,371.2 1 6 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b0eb1cb --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/tkrop/testing + +go 1.19 + +require ( + github.com/golang/mock v1.6.0 + github.com/stretchr/testify v1.8.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a0c1e76 --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mock/mock.go b/mock/mock.go new file mode 100644 index 0000000..d1b78e4 --- /dev/null +++ b/mock/mock.go @@ -0,0 +1,371 @@ +package mock + +import ( + "fmt" + "reflect" + "sync" + + "github.com/golang/mock/gomock" +) + +// DetachMode defines the mode for detaching mock calls. +type DetachMode int + +const ( + // None mode to not detach mode. + None DetachMode = 0 + // Head mode to detach head, i.e. do not order mock calls after predecessor + // mock calls provided via context. + Head DetachMode = 1 + // Tail mode to deteach tail, i.e. do not order mock calls before successor + // mock calls provided via context. + Tail DetachMode = 2 + // Both mode to detach tail and head, i.e. do neither order mock calls after + // predecessor nor before successor provided via context. + Both DetachMode = 3 +) + +// String return string representation of detach mode. +func (m DetachMode) String() string { + switch m { + case None: + return "None" + case Head: + return "Head" + case Tail: + return "Tail" + case Both: + return "Both" + default: + return "Unknown" + } +} + +type ( + // Call alias for `gomock.Call` + Call = gomock.Call + // Controller alias for `gomock.Controller` + Controller = gomock.Controller + + // chain is the type to signal that mock calls must and will be orders in a + // chain of mock calls. + chain any + // parallel is the type to signal that mock calls must and will be orders + // in a parallel set of mock calls. + parallel any + // detachHead is the type to signal that the leading mock call must and + // will be detached from its predecessor. + detachHead any + // detachTail is the type to signal that the trailing mock call must and + // will be detached from its successor. + detachTail any + // detachBoth is the type to signal that the mock call must and will be + // deteched from its predecessor as well as from its successor. + detachBoth any +) + +// SetupFunc common mock setup function signature. +type SetupFunc func(*Mocks) any + +// Mocks common mock handler. +type Mocks struct { + ctrl *Controller + wg *sync.WaitGroup + mocks map[reflect.Type]any +} + +// NewMock creates a new mock handler using given test reporter (`*testing.T`). +func NewMock(t gomock.TestReporter) *Mocks { + return &Mocks{ + ctrl: gomock.NewController(t), + wg: &sync.WaitGroup{}, + mocks: map[reflect.Type]any{}, + } +} + +// Expect configures the mock handler to expect the given mock function calls. +func (mocks *Mocks) Expect(fncalls SetupFunc) *Mocks { + if fncalls != nil { + Setup(fncalls)(mocks) + } + return mocks +} + +// WaitGroup returns the `WaitGroup` of the mock handler to wait at when the +// tests comprises mock calls in detached `go-routines`. +func (mocks *Mocks) WaitGroup() *sync.WaitGroup { + return mocks.wg +} + +// Get resolves the actual mock from the mock handler by providing the +// constructor function generated by `gomock` to create a new mock. +func Get[T any](mocks *Mocks, creator func(*Controller) *T) *T { + ctype := reflect.TypeOf(creator) + tmock, ok := mocks.mocks[ctype] + if ok && tmock != nil { + return tmock.(*T) + } + tmock = creator(mocks.ctrl) + mocks.mocks[ctype] = tmock + return tmock.(*T) +} + +// Setup creates only a lazily ordered set of mock calls that is detached from +// the parent setup by returning no calls for chaining. The mock calls created +// by the setup are only validated in so far in relation to each other, that +// `gomock` delivers results for the same mock call receiver in the order +// provided during setup. +func Setup[T any](calls ...func(*T) any) func(*T) any { + return func(mock *T) any { + for _, call := range calls { + inOrder([]*Call{}, []detachBoth{call(mock)}) + } + return nil + } +} + +// Chain creates a single chain of mock calls that is validated by `gomock`. +// If the execution order deviates from the order defined in the chain, the +// test validation fails. The method returns the full mock calls tree to allow +// chaining with other ordered setup method. +func Chain[T any](fncalls ...func(*T) any) func(*T) any { + return func(mock *T) any { + calls := make([]chain, 0, len(fncalls)) + for _, fncall := range fncalls { + calls = chainCalls(calls, fncall(mock)) + } + return calls + } +} + +// Parallel creates a set of parallel set of mock calls that is validated by +// `gomock`. While the parallel setup provids some freedom, this still defines +// constrainst with repect to parent and child setup methods, e.g. when setting +// up parallel chains in a chain, each parallel chains needs to follow the last +// mock call and finish before the following mock call. +// +// If the execution order deviates from the order defined by the parallel +// context, the test validation fails. The method returns the full set of mock +// calls to allow combining them with other ordered setup methods. +func Parallel[T any](fncalls ...func(*T) any) func(*T) any { + return func(mock *T) any { + calls := make([]parallel, 0, len(fncalls)) + for _, fncall := range fncalls { + calls = append(calls, fncall(mock).(parallel)) + } + return calls + } +} + +// Detach detach given mock call setup using given detach mode. It is possible +// to detach the mock call from the preceeding mock calls (`Head`), from the +// succeeding mock calls (`Tail`), or from both as used in `Setup`. +func Detach[T any](mode DetachMode, fncall func(*T) any) func(*T) any { + return func(mock *T) any { + switch mode { + case None: + return fncall(mock) + case Head: + return []detachHead{fncall(mock)} + case Tail: + return []detachTail{fncall(mock)} + case Both: + return []detachBoth{fncall(mock)} + default: + panic(ErrDetachMode(mode)) + } + } +} + +// Sub returns the sub slice of mock calls starting at index `from` up to index +// `to` inclduing. A negative value is used to calculate an index from the end +// of the slice. If the index of `from` is higher as the index `to`, the +// indexes are automatically switched. The returned sub slice of mock calls +// keeps its original semantic. +func Sub[T any](from, to int, fncall func(*T) any) func(*T) any { + return func(mock *T) any { + calls := fncall(mock) + switch calls := any(calls).(type) { + case *Call: + inOrder([]*Call{}, calls) + return GetSubSlice(from, to, []any{calls}) + case []chain: + inOrder([]*Call{}, calls) + return GetSubSlice(from, to, calls) + case []parallel: + inOrder([]*Call{}, calls) + return GetSubSlice(from, to, calls) + case []detachBoth: + panic(ErrDetachNotAllowed(Both)) + case []detachHead: + panic(ErrDetachNotAllowed(Head)) + case []detachTail: + panic(ErrDetachNotAllowed(Tail)) + case nil: + return nil + default: + panic(ErrNoCall(calls)) + } + } +} + +// GetSubSlice returns the sub slice of mock calls starting at index `from` +// up to index `to` inclduing. A negative value is used to calculate an index +// from the end of the slice. If the index `from` is after the index `to`, the +// indexes are automatically switched. +func GetSubSlice[T any](from, to int, calls []T) any { + from = getPos(from, calls) + to = getPos(to, calls) + if from > to { + return calls[to : from+1] + } else if from < to { + return calls[from : to+1] + } + return calls[from] +} + +// getPos returns the actual call position evaluating negative positions +// from the back of the mock call slice. +func getPos[T any](pos int, calls []T) int { + len := len(calls) + if pos < 0 { + pos = len + pos + if pos < 0 { + return 0 + } + return pos + } else if pos < len { + return pos + } + return len - 1 +} + +// chainCalls joins arbitray slices, single mock calls, and parallel mock calls +// into a single mock call slice and slice of mock slices. If the provided mock +// calls do not contain mock calls or slices of them, the join fails with a +// `panic`. +func chainCalls(calls []chain, more ...any) []chain { + for _, call := range more { + switch call := any(call).(type) { + case *Call: + calls = append(calls, call) + case []chain: + calls = append(calls, call...) + case []parallel: + calls = append(calls, call) + case []detachBoth: + calls = append(calls, call) + case []detachHead: + calls = append(calls, call) + case []detachTail: + calls = append(calls, call) + case nil: + default: + panic(ErrNoCall(call)) + } + } + return calls +} + +// inOrder creates an order of the given mock call using given anchors as +// predecessor and return the mock call as next anchor. The created order +// depends on the actual type of the mock call (slice). +func inOrder(anchors []*Call, call any) []*Call { + switch call := any(call).(type) { + case *Call: + return inOrderCall(anchors, call) + case []parallel: + return inOrderParallel(anchors, call) + case []chain: + return inOrderChain(anchors, call) + case []detachBoth: + return inOrderDetachBoth(anchors, call) + case []detachHead: + return inOrderDetachHead(anchors, call) + case []detachTail: + return inOrderDetachTail(anchors, call) + case nil: + return anchors + default: + panic(ErrNoCall(call)) + } +} + +// inOrderCall creates an order for the given mock call using the given achors +// as predecessor and resturn the call as next anchor. +func inOrderCall(anchors []*Call, call *Call) []*Call { + if len(anchors) != 0 { + for _, anchor := range anchors { + if anchor != call { + call.After(anchor) + } + } + } + return []*Call{call} +} + +// inOrderChain creates a chain order of the given mock calls using given +// anchors as predecessor and return the last mocks call as next anchor. +func inOrderChain(anchors []*Call, calls []chain) []*Call { + for _, call := range calls { + anchors = inOrder(anchors, call) + } + return anchors +} + +// inOrderParallel creates a parallel order the given mock calls using the +// anchors as predecessors and return list of all (last) mock calls as next +// anchors. +func inOrderParallel(anchors []*Call, calls []parallel) []*Call { + nanchors := make([]*Call, 0, len(calls)) + for _, call := range calls { + nanchors = append(nanchors, inOrder(anchors, call)...) + } + return nanchors +} + +// inOrderDetachBoth creates a detached set of mock calls without using the +// anchors as predecessor nor returning the last mock calls as next anchor. +func inOrderDetachBoth(anchors []*Call, calls []detachBoth) []*Call { + for _, call := range calls { + inOrder(nil, call) + } + return anchors +} + +// inOrderDetachHead creates a head detached set of mock calls without using +// the anchors as predecessor. The anchors are forwarded together with the new +// mock calls as next anchors. +func inOrderDetachHead(anchors []*Call, calls []detachHead) []*Call { + for _, call := range calls { + anchors = append(anchors, inOrder(nil, call)...) + } + return anchors +} + +// inOrderDetachTail creates a tail detached set of mock calls using the +// anchors as predessors but without adding the mock calls as next anchors. +// The provided anchors are provided as next anchors. +func inOrderDetachTail(anchors []*Call, calls []detachTail) []*Call { + for _, call := range calls { + inOrder(anchors, call) + } + return anchors +} + +// ErrNoCall creates an error with given call type to panic on inorrect call +// type. +func ErrNoCall(call any) error { + return fmt.Errorf("type [%v] is not based on *gomock.Call", + reflect.TypeOf(call)) +} + +// ErrDetachMode creates an error that the given detach mode is not supported. +func ErrDetachMode(mode DetachMode) error { + return fmt.Errorf("detach mode [%v] is not supported", mode) +} + +// ErrDetachNotAllowed creates an error that detach. +func ErrDetachNotAllowed(mode DetachMode) error { + return fmt.Errorf("detach [%v] not supported in sub", mode) +} diff --git a/mock/mock_iface_test.go b/mock/mock_iface_test.go new file mode 100644 index 0000000..0b4b696 --- /dev/null +++ b/mock/mock_iface_test.go @@ -0,0 +1,60 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: mock_test.go + +// Package mock is a generated GoMock package. +package mock + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockIFace is a mock of IFace interface. +type MockIFace struct { + ctrl *gomock.Controller + recorder *MockIFaceMockRecorder +} + +// MockIFaceMockRecorder is the mock recorder for MockIFace. +type MockIFaceMockRecorder struct { + mock *MockIFace +} + +// NewMockIFace creates a new mock instance. +func NewMockIFace(ctrl *gomock.Controller) *MockIFace { + mock := &MockIFace{ctrl: ctrl} + mock.recorder = &MockIFaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIFace) EXPECT() *MockIFaceMockRecorder { + return m.recorder +} + +// CallA mocks base method. +func (m *MockIFace) CallA(arg0 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "CallA", arg0) +} + +// CallA indicates an expected call of CallA. +func (mr *MockIFaceMockRecorder) CallA(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CallA", reflect.TypeOf((*MockIFace)(nil).CallA), arg0) +} + +// CallB mocks base method. +func (m *MockIFace) CallB(arg0 string) string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CallB", arg0) + ret0, _ := ret[0].(string) + return ret0 +} + +// CallB indicates an expected call of CallB. +func (mr *MockIFaceMockRecorder) CallB(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CallB", reflect.TypeOf((*MockIFace)(nil).CallB), arg0) +} diff --git a/mock/mock_test.go b/mock/mock_test.go new file mode 100644 index 0000000..650b7e2 --- /dev/null +++ b/mock/mock_test.go @@ -0,0 +1,518 @@ +package mock_test + +import ( + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tkrop/testing/mock" + "github.com/tkrop/testing/perm" + "github.com/tkrop/testing/test" +) + +//go:generate mockgen -package=mock -destination=mock_iface_test.go -source=mock_test.go IFace + +type IFace interface { + CallA(string) + CallB(string) string +} + +func CallA(input string) mock.SetupFunc { + return func(mocks *mock.Mocks) any { + mocks.WaitGroup().Add(1) + return mock.Get(mocks, mock.NewMockIFace).EXPECT(). + CallA(input).Times(1). + Do(func(arg any) { + defer mocks.WaitGroup().Done() + }) + } +} + +func CallB(input string, output string) mock.SetupFunc { + return func(mocks *mock.Mocks) any { + mocks.WaitGroup().Add(1) + return mock.Get(mocks, mock.NewMockIFace).EXPECT(). + CallB(input).Return(output).Times(1). + Do(func(arg any) { + defer mocks.WaitGroup().Done() + }) + } +} + +func NoCall() mock.SetupFunc { + return func(mocks *mock.Mocks) any { + return mock.Get(mocks, mock.NewMockIFace).EXPECT() + } +} + +func MockSetup(t gomock.TestReporter, mockSetup mock.SetupFunc) *mock.Mocks { + return mock.NewMock(t).Expect(mockSetup) +} + +func MockValidate( + t *test.TestingT, mocks *mock.Mocks, validate func(*test.TestingT, *mock.Mocks), failing bool, +) { + if failing { + // we need to execute failing test synchronous, since we setup full + // permutations instead of stopping setup on first failing mock calls. + validate(t, mocks) + } else { + // Test proper usage of `WaitGroup` on non-failing validation. + validate(t, mocks) + mocks.WaitGroup().Wait() + } +} + +func SetupPermTestABC(mocks *mock.Mocks) *perm.PermTest { + iface := mock.Get(mocks, mock.NewMockIFace) + return perm.NewPermTest(mocks, + perm.TestMap{ + "a": func(t *test.TestingT) { iface.CallA("a") }, + "b1": func(t *test.TestingT) { + assert.Equal(t, "c", iface.CallB("b")) + }, + "b2": func(t *test.TestingT) { + assert.Equal(t, "d", iface.CallB("b")) + }, + "c": func(t *test.TestingT) { iface.CallA("c") }, + }) + +} + +func SetupPermTestABCD(mocks *mock.Mocks) *perm.PermTest { + iface := mock.Get(mocks, mock.NewMockIFace) + return perm.NewPermTest(mocks, + perm.TestMap{ + "a": func(t *test.TestingT) { iface.CallA("a") }, + "b": func(t *test.TestingT) { iface.CallA("b") }, + "c": func(t *test.TestingT) { + assert.Equal(t, "d", iface.CallB("c")) + }, + "d": func(t *test.TestingT) { + assert.Equal(t, "e", iface.CallB("d")) + }, + }) + +} + +func SetupPermTestABCDEF(mocks *mock.Mocks) *perm.PermTest { + iface := mock.Get(mocks, mock.NewMockIFace) + return perm.NewPermTest(mocks, + perm.TestMap{ + "a": func(t *test.TestingT) { iface.CallA("a") }, + "b": func(t *test.TestingT) { iface.CallA("b") }, + "c": func(t *test.TestingT) { + assert.Equal(t, "d", iface.CallB("c")) + }, + "d": func(t *test.TestingT) { + assert.Equal(t, "e", iface.CallB("d")) + }, + "e": func(t *test.TestingT) { iface.CallA("e") }, + "f": func(t *test.TestingT) { iface.CallA("f") }, + }) + +} + +var testSetupParams = perm.PermMap{ + "b2-b1-a-c": test.ExpectFailure, + "b2-b1-c-a": test.ExpectFailure, + "b2-c-b1-a": test.ExpectFailure, + "b2-a-b1-c": test.ExpectFailure, + "b2-c-a-b1": test.ExpectFailure, + "b2-a-c-b1": test.ExpectFailure, + "a-b2-b1-c": test.ExpectFailure, + "c-b2-b1-a": test.ExpectFailure, + "a-b2-c-b1": test.ExpectFailure, + "c-b2-a-b1": test.ExpectFailure, + "c-a-b2-b1": test.ExpectFailure, + "a-c-b2-b1": test.ExpectFailure, +} + +func TestSetup(t *testing.T) { + perms := perm.PermRemain(testSetupParams, test.ExpectSuccess) + for message, expect := range perms { + t.Run(message, test.Run(expect, func(t *test.TestingT) { + require.NotEmpty(t, message) + + // Given + perm := strings.Split(message, "-") + mockSetup := mock.Setup( + CallA("a"), + mock.Setup( + CallB("b", "c"), + CallB("b", "d"), + ), + CallA("c"), + ) + mock := MockSetup(t, mockSetup) + + // When + test := SetupPermTestABC(mock) + + // Then + test.Test(t, perm, expect) + })) + } +} + +var testChainParams = perm.PermMap{ + "a-b1-b2-c": test.ExpectSuccess, +} + +func TestChain(t *testing.T) { + perms := perm.PermRemain(testChainParams, test.ExpectFailure) + for message, expect := range perms { + t.Run(message, test.Run(expect, func(t *test.TestingT) { + require.NotEmpty(t, message) + + // Given + perm := strings.Split(message, "-") + mockSetup := mock.Chain( + CallA("a"), + mock.Chain( + CallB("b", "c"), + CallB("b", "d"), + ), + CallA("c"), + ) + mock := MockSetup(t, mockSetup) + + // When + test := SetupPermTestABC(mock) + + // Then + test.Test(t, perm, expect) + })) + } +} + +var testSetupChainParams = perm.PermMap{ + "a-b-c-d": test.ExpectSuccess, + "a-c-b-d": test.ExpectSuccess, + "a-c-d-b": test.ExpectSuccess, + "c-a-b-d": test.ExpectSuccess, + "c-a-d-b": test.ExpectSuccess, + "c-d-a-b": test.ExpectSuccess, +} + +func TestSetupChain(t *testing.T) { + perms := perm.PermRemain(testSetupChainParams, test.ExpectFailure) + for message, expect := range perms { + t.Run(message, test.Run(expect, func(t *test.TestingT) { + require.NotEmpty(t, message) + + // Given + perm := strings.Split(message, "-") + + // Basic setup of two independent chains. + mockSetup := mock.Setup( + mock.Chain( + CallA("a"), + CallA("b"), + ), + mock.Chain( + CallB("c", "d"), + CallB("d", "e"), + ), + ) + mock := MockSetup(t, mockSetup) + + // When + test := SetupPermTestABCD(mock) + + // Then + test.Test(t, perm, expect) + })) + } +} + +func TestChainSetup(t *testing.T) { + perms := perm.PermRemain(testSetupChainParams, test.ExpectFailure) + for message, expect := range perms { + t.Run(message, test.Run(expect, func(t *test.TestingT) { + require.NotEmpty(t, message) + + // Given + perm := strings.Split(message, "-") + + // Frail setup to detach a (sub-)chain. + mockSetup := mock.Chain( + CallA("a"), + CallA("b"), + mock.Setup( // detaching (sub-)chain. + mock.Chain( + CallB("c", "d"), + CallB("d", "e"), + ), + ), + ) + mock := MockSetup(t, mockSetup) + + // When + test := SetupPermTestABCD(mock) + + // Then + test.Test(t, perm, expect) + })) + } +} + +var testParallelChainParams = perm.PermMap{ + "a-b-c-d-e-f": test.ExpectSuccess, + "a-b-c-e-d-f": test.ExpectSuccess, + "a-b-e-c-d-f": test.ExpectSuccess, + "a-c-b-d-e-f": test.ExpectSuccess, + "a-c-d-b-e-f": test.ExpectSuccess, + "a-c-d-e-b-f": test.ExpectSuccess, + "a-c-b-e-d-f": test.ExpectSuccess, + "a-c-e-d-b-f": test.ExpectSuccess, + "a-c-e-b-d-f": test.ExpectSuccess, + "a-e-b-c-d-f": test.ExpectSuccess, + "a-e-c-b-d-f": test.ExpectSuccess, + "a-e-c-d-b-f": test.ExpectSuccess, +} + +func TestParallelChain(t *testing.T) { + perms := perm.PermRemain(testParallelChainParams, test.ExpectFailure) + for message, expect := range perms { + t.Run(message, test.Run(expect, func(t *test.TestingT) { + require.NotEmpty(t, message) + + // Given + perm := strings.Split(message, "-") + mockSetup := mock.Chain( + CallA("a"), + mock.Parallel( + CallA("b"), + mock.Chain( + CallB("c", "d"), + CallB("d", "e"), + ), + mock.Parallel( + CallA("e"), + ), + ), + CallA("f"), + ) + mock := MockSetup(t, mockSetup) + + // When + test := SetupPermTestABCDEF(mock) + + // Then + test.Test(t, perm, expect) + })) + } +} + +var testChainSubParams = perm.PermMap{ + "a-b-c-d-e-f": test.ExpectSuccess, + "a-c-b-d-e-f": test.ExpectSuccess, + "a-c-d-b-e-f": test.ExpectSuccess, + "a-c-d-e-b-f": test.ExpectSuccess, + "f-a-b-c-d-e": test.ExpectSuccess, + "a-f-b-c-d-e": test.ExpectSuccess, + "a-b-f-c-d-e": test.ExpectSuccess, + "a-b-c-f-d-e": test.ExpectSuccess, + "a-b-c-d-f-e": test.ExpectSuccess, + "a-c-d-e-f-b": test.ExpectSuccess, + + "b-a-c-d-e-f": test.ExpectFailure, + "c-a-b-d-e-f": test.ExpectFailure, + "d-a-b-c-e-f": test.ExpectFailure, + "a-b-c-e-d-f": test.ExpectFailure, + "a-b-d-e-c-f": test.ExpectFailure, +} + +func TestChainSub(t *testing.T) { + perms := testChainSubParams + // perms := PermRemain(testChainSubParams, test.ExpectFailure) + for message, expect := range perms { + t.Run(message, test.Run(expect, func(t *test.TestingT) { + require.NotEmpty(t, message) + + // Given + perm := strings.Split(message, "-") + mockSetup := mock.Chain( + mock.Sub(0, 0, mock.Chain( + CallA("a"), + CallA("b"), + )), + mock.Sub(0, -1, mock.Parallel( + CallB("c", "d"), + CallB("d", "e"), + )), + mock.Sub(0, 0, CallA("e")), + mock.Sub(2, 2, mock.Setup(CallA("f"))), + ) + mock := MockSetup(t, mockSetup) + + // When + test := SetupPermTestABCDEF(mock) + + // Then + test.Test(t, perm, expect) + })) + } +} + +var testDetachParams = perm.PermMap{ + "a-b-c-d": test.ExpectSuccess, + "a-b-d-c": test.ExpectSuccess, + "a-d-b-c": test.ExpectSuccess, + "b-a-c-d": test.ExpectSuccess, + "b-a-d-c": test.ExpectSuccess, + "b-d-a-c": test.ExpectSuccess, + "d-a-b-c": test.ExpectSuccess, + "d-b-a-c": test.ExpectSuccess, +} + +func TestDetach(t *testing.T) { + perms := perm.PermRemain(testDetachParams, test.ExpectFailure) + for message, expect := range perms { + t.Run(message, test.Run(expect, func(t *test.TestingT) { + require.NotEmpty(t, message) + + // Given + perm := strings.Split(message, "-") + mockSetup := mock.Chain( + mock.Detach(mock.None, CallA("a")), + mock.Detach(mock.Head, CallA("b")), + mock.Detach(mock.Tail, CallB("c", "d")), + mock.Detach(mock.Both, CallB("d", "e")), + ) + mock := MockSetup(t, mockSetup) + + // When + test := SetupPermTestABCD(mock) + + // Then + test.Test(t, perm, expect) + })) + } +} + +var testPanicParams = map[string]struct { + setup mock.SetupFunc + expectError error +}{ + "setup": { + setup: mock.Setup(NoCall()), + expectError: mock.ErrNoCall(mock.NewMockIFace(nil).EXPECT()), + }, + "chain": { + setup: mock.Chain(NoCall()), + expectError: mock.ErrNoCall(mock.NewMockIFace(nil).EXPECT()), + }, + "parallel": { + setup: mock.Parallel(NoCall()), + expectError: mock.ErrNoCall(mock.NewMockIFace(nil).EXPECT()), + }, + "detach": { + setup: mock.Detach(4, NoCall()), + expectError: mock.ErrDetachMode(4), + }, + "sub": { + setup: mock.Sub(0, 0, NoCall()), + expectError: mock.ErrNoCall(mock.NewMockIFace(nil).EXPECT()), + }, + "sub-head": { + setup: mock.Sub(0, 0, mock.Detach(mock.Head, NoCall())), + expectError: mock.ErrDetachNotAllowed(mock.Head), + }, + "sub-tail": { + setup: mock.Sub(0, 0, mock.Detach(mock.Tail, NoCall())), + expectError: mock.ErrDetachNotAllowed(mock.Tail), + }, + "sub-both": { + setup: mock.Sub(0, 0, mock.Detach(mock.Both, NoCall())), + expectError: mock.ErrDetachNotAllowed(mock.Both), + }, +} + +func TestPanic(t *testing.T) { + for message, param := range testPanicParams { + t.Run(message, func(t *testing.T) { + require.NotEmpty(t, message) + + // Given + defer func() { + err := recover() + assert.Equal(t, param.expectError, err) + }() + + // When + mock := MockSetup(t, param.setup) + + // Then + require.Fail(t, "not paniced") + mock.WaitGroup().Wait() + }) + } +} + +var testGetSubSliceParams = map[string]struct { + slice []any + from, to int + expectSlice any +}{ + "first": { + slice: []any{"a", "b", "c", "d", "e"}, + from: 0, to: 0, + expectSlice: "a", + }, + "last": { + slice: []any{"a", "b", "c", "d", "e"}, + from: -1, to: -1, + expectSlice: "e", + }, + "middle": { + slice: []any{"a", "b", "c", "d", "e"}, + from: 2, to: 2, + expectSlice: "c", + }, + "begin": { + slice: []any{"a", "b", "c", "d", "e"}, + from: 0, to: 2, + expectSlice: []any{"a", "b", "c"}, + }, + "end": { + slice: []any{"a", "b", "c", "d", "e"}, + from: -3, to: -1, + expectSlice: []any{"c", "d", "e"}, + }, + "all": { + slice: []any{"a", "b", "c", "d", "e"}, + from: 0, to: -1, + expectSlice: []any{"a", "b", "c", "d", "e"}, + }, + "sub": { + slice: []any{"a", "b", "c", "d", "e"}, + from: -2, to: 1, + expectSlice: []any{"b", "c", "d"}, + }, + "out-of-bound": { + slice: []any{"a", "b", "c", "d", "e"}, + from: -7, to: 7, + expectSlice: []any{"a", "b", "c", "d", "e"}, + }, +} + +func TestGetSubSlice(t *testing.T) { + for message, param := range testGetSubSliceParams { + t.Run(message, func(t *testing.T) { + require.NotEmpty(t, message) + + // Given + + // When + slice := mock.GetSubSlice(param.from, param.to, param.slice) + + // Then + assert.Equal(t, param.expectSlice, slice) + }) + } +} diff --git a/perm/perm.go b/perm/perm.go new file mode 100644 index 0000000..62aadbf --- /dev/null +++ b/perm/perm.go @@ -0,0 +1,82 @@ +package perm + +import ( + "strings" + + "github.com/stretchr/testify/require" + + "github.com/tkrop/testing/mock" + "github.com/tkrop/testing/test" +) + +type TestMap map[string]func(t *test.TestingT) +type PermMap map[string]test.Expect + +// PermTest permutation test. +type PermTest struct { + mocks *mock.Mocks + tests TestMap +} + +// NewPermTest creates a new permutation test with given mock and given +// permutation test map. +func NewPermTest(mocks *mock.Mocks, tests TestMap) *PermTest { + return &PermTest{ + mocks: mocks, + tests: tests, + } +} + +// TestPerm tests a single permutation given by the string slice. +func (p *PermTest) TestPerm(t *test.TestingT, perm []string) { + require.Equal(t, len(p.tests), len(perm), + "permutation needs to cover all tests") + for _, value := range perm { + p.tests[value](t) + } +} + +// Test executes a permutation test with given permutation and expected result. +func (p *PermTest) Test(t *test.TestingT, perm []string, expect test.Expect) { + switch expect { + case test.ExpectSuccess: + // Test proper usage of `WaitGroup` on non-failing validation. + p.TestPerm(t, perm) + p.mocks.WaitGroup().Wait() + case test.ExpectFailure: + // we need to execute failing test synchronous, since we setup full + // permutations instead of stopping setup on first failing mock calls. + p.TestPerm(t, perm) + } +} + +// PermRemain calculate and add the missing permutations and add it with +// expected result to the given permmutation map. +func PermRemain(perms PermMap, expect test.Expect) PermMap { + for key := range perms { + PermSlice(strings.Split(key, "-"), func(perm []string) { + key := strings.Join(perm, "-") + if _, ok := perms[key]; !ok { + perms[key] = expect + } + }, 0) + break // we only need to permutate the first key. + } + return perms +} + +// PermSlice permutates the given slice starting at the position given by and +// call the `do` function on each permutation to collect the result. For a full +// permutation the `index` must start with `0`. +func PermSlice[T any](slice []T, do func([]T), index int) { + if index <= len(slice) { + PermSlice(slice, do, index+1) + for offset := index + 1; offset < len(slice); offset++ { + slice[index], slice[offset] = slice[offset], slice[index] + PermSlice(slice, do, index+1) + slice[index], slice[offset] = slice[offset], slice[index] + } + } else { + do(slice) + } +} diff --git a/test/testing.go b/test/testing.go new file mode 100644 index 0000000..0f3cf1a --- /dev/null +++ b/test/testing.go @@ -0,0 +1,104 @@ +package test + +import ( + "fmt" + "runtime" + "sync" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +const ( + ExpectSuccess Expect = true + ExpectFailure Expect = false +) + +type Expect bool + +// TestingT is a minimal testing abstraction of `testing.T` that supports +// `testiy` and `gomock`. It can be used as drop in replacement to check +// for expected test failures. +type TestingT struct { + require.TestingT + gomock.TestReporter + t *testing.T + expect Expect + failed bool +} + +// NewTestingT creates a new minimal test context based on the given `go-test` +// context. +func NewTestingT(t *testing.T, expect Expect) *TestingT { + return &TestingT{t: t, expect: expect} +} + +// FailNow implements a detached failure handling for `testing.T.FailNow`. +func (m *TestingT) FailNow() { + m.failed = true + if m.expect == ExpectSuccess { + m.t.FailNow() + } + runtime.Goexit() +} + +// Errorf implements a detached failure handling for `testing.T.Errorf`. +func (m *TestingT) Errorf(format string, args ...any) { + m.failed = true + if m.expect == ExpectSuccess { + m.t.Errorf(format, args...) + } +} + +// Fatalf implements a detached failure handling for `testing.T.Fatelf`. +func (m *TestingT) Fatalf(format string, args ...any) { + m.failed = true + if m.expect == ExpectSuccess { + m.t.Fatalf(format, args...) + } + runtime.Goexit() +} + +// test execution the test function in a safe detached environment and check +// the failure state after the test function has finished. If the test result +// is not according to expectation, a failure is created in the parent test +// context. +func (m *TestingT) test(test func(*TestingT)) *TestingT { + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + test(m) + }() + wg.Wait() + + switch m.expect { + case ExpectSuccess: + require.False(m.t, m.failed, + fmt.Sprintf("Expected test %s to succeed", m.t.Name())) + case ExpectFailure: + require.True(m.t, m.failed, + fmt.Sprintf("Expected test %s to fail", m.t.Name())) + } + return m +} + +// Run runs the given test function and checks whether the result is according +// to the expection. +func Run(expect Expect, test func(*TestingT)) func(*testing.T) { + return func(t *testing.T) { + NewTestingT(t, expect).test(test) + } +} + +// Failure expects the given test function to fail. If this is the case, the +// failure is intercepted and the test run succeeds. +func Failure(t *testing.T, test func(*TestingT)) { + NewTestingT(t, ExpectFailure).test(test) +} + +// Success expects the given test function to succeed as usually. +func Success(t *testing.T, test func(*TestingT)) { + NewTestingT(t, ExpectSuccess).test(test) +}