pulumi/cmd/pulumi-test-language/testing.go

255 lines
6.4 KiB
Go

// Copyright 2016-2023, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
"fmt"
"path/filepath"
"runtime"
"runtime/debug"
"sync"
mapset "github.com/deckarep/golang-set/v2"
"github.com/pulumi/pulumi/pkg/v3/display"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/result"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// L holds the state for the current language test.
//
// It provides an interface similar to testing.T,
// allowing its use with testing libraries like Testify.
type L struct {
mu sync.RWMutex // guards the fields below
// Whether this test has already failed.
failed bool
// Messages logged to l.Errorf or l.Logf.
logs []string
// Functions marked helpers with L.Helper().
// These names are from the runtime.Frame.Function field.
// They're fully qualified with package names.
helpers mapset.Set[string]
}
// Helper marks the calling function as a test helper function.
// When printing file and line information, that function will be skipped.
func (l *L) Helper() {
pc, _, _, ok := runtime.Caller(1) // skip this function
if !ok {
return // unlikely but not worth panicking over
}
frame, _ := runtime.CallersFrames([]uintptr{pc}).Next()
if frame.Function == "" {
return
}
l.mu.Lock()
defer l.mu.Unlock()
if l.helpers == nil {
l.helpers = mapset.NewSet[string]()
}
l.helpers.Add(frame.Function)
}
// FailNow marks this test as having failed and halts execution.
func (l *L) FailNow() {
l.Fail()
runtime.Goexit()
}
// Fail marks this test as failed but keeps executing.
func (l *L) Fail() {
l.mu.Lock()
defer l.mu.Unlock()
l.failed = true
}
// Failed returns whether this test has failed.
func (l *L) Failed() bool {
l.mu.RLock()
defer l.mu.RUnlock()
return l.failed
}
// Errorf records the given error message and marks this test as failed.
func (l *L) Errorf(format string, args ...interface{}) {
l.log(1, fmt.Sprintf(format, args...))
l.Fail()
}
// Logf records the given message in the L's logs.
func (l *L) Logf(format string, args ...interface{}) {
l.log(1, fmt.Sprintf(format, args...))
}
// log records the given message in the L's logs.
//
// Skip specifies the number of stack frames to skip
// when recording the caller's location.
// 0 refers to the immediate caller of log.
//
// Typically, when used from an exported method on L,
// most callers will want to pass skip=1 to skip themselves
// and record the location of their caller.
func (l *L) log(skip int, msg string) {
file, line := "???", 1
if frame, ok := l.callerFrame(skip + 1); ok {
file, line = frame.File, frame.Line
}
msg = fmt.Sprintf("%s:%d: %s", filepath.Base(file), line, msg)
l.mu.Lock()
l.logs = append(l.logs, msg)
l.mu.Unlock()
}
// Maximal stack depth to search for the caller's frame.
const _maxStackDepth = 50
// callerFrame searches the call stack for the first frame
// that isn't a helper function.
//
// skip specifies the initial number of frames to skip
// with 0 referring to the immediate caller of callerFrame.
func (l *L) callerFrame(skip int) (frame runtime.Frame, ok bool) {
var pc [_maxStackDepth]uintptr
n := runtime.Callers(skip+2, pc[:]) // skip runtime.Callers and callerFrame
if n == 0 {
return frame, false
}
l.mu.RLock()
defer l.mu.RUnlock()
frames := runtime.CallersFrames(pc[:n])
for {
frame, more := frames.Next()
if !l.helpers.Contains(frame.Function) {
// Not a helper. Use this frame.
return frame, true
}
if !more {
break
}
}
return frame, false // no non-helper frames found
}
// WithL runs the given function with a new L,
// blocking until the function returns.
//
// It returns the information recorded by the L.
func WithL(f func(*L)) LResult {
// To be able to implement FailNow in the L,
// we need to run it in a separate goroutine
// so that we can call runtime.Goexit.
done := make(chan struct{})
var l L
go func() {
defer func() {
if r := recover(); r != nil {
l.failed = true
l.logs = append(l.logs,
fmt.Sprintf("panic: %v\n\n%s", r, debug.Stack()))
}
close(done)
}()
f(&l)
}()
<-done
return LResult{
Failed: l.failed,
Messages: l.logs,
}
}
// LResult is the result of running a language test.
type LResult struct {
// Failed is true if the test failed.
Failed bool
// Messages contains the messages logged by the test.
//
// This doesn't necessarily mean that the test failed.
// For example, a test may log debugging information
// that is only useful when the test fails.
Messages []string
}
// TestingT is a subset of the testing.T interface.
// [L] implements this interface.
type TestingT interface {
Helper()
FailNow()
Fail()
Failed() bool
Errorf(string, ...interface{})
Logf(string, ...interface{})
}
var (
_ TestingT = (*L)(nil)
_ require.TestingT = (TestingT)(nil) // ensure testify compatibility
)
func assertStackResource(t TestingT, res result.Result, changes display.ResourceChanges) (ok bool) {
t.Helper()
ok = true
ok = ok && assert.Nil(t, res, "expected no error, got %v", res)
ok = ok && assert.NotEmpty(t, changes, "expected at least 1 StepOp")
ok = ok && assert.NotZero(t, changes[deploy.OpCreate], "expected at least 1 Create")
return ok
}
func requireStackResource(t TestingT, res result.Result, changes display.ResourceChanges) {
t.Helper()
if !assertStackResource(t, res, changes) {
t.FailNow()
}
}
// assertPropertyMapMember asserts that the given property map has a member with the given key and value.
func assertPropertyMapMember(
t TestingT,
props resource.PropertyMap,
key string,
want resource.PropertyValue,
) (ok bool) {
t.Helper()
got, ok := props[resource.PropertyKey(key)]
if !assert.True(t, ok, "expected property %q", key) {
return false
}
return assert.Equal(t, want, got, "expected property %q to be %v", key, want)
}