-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adding plan checks for ExpectKnownValue
, ExpectKnownOutputValue
, and ExpectKnownOutputValueAtPath
#248
Conversation
…pe and value, and known value type and value (#243)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall super excited about this as its a really nice enhancement for the testing logic. Please let me know if you want to chat about any of this.
knownvalue/known_value.go
Outdated
// Equal should perform equality testing. | ||
Equal(other any) bool |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Open question: Do we want to call this "check", "match", or another name? While exact equality is one use case with these, there are other use cases as well, e.g.
- Regular expressions for strings
- Minimum/maximum for numbers
- Partial matching for collections or objects
- Returning true for any value
I guess I'm just trying to ensure folks aren't confused or turned away for the not-exactly-equal use cases.
Seeing everything written out now, I almost wonder if this interface should be something like:
type Check interface {
CheckValue(value any) error
String() string
}
There's at least two benefits I see here beyond the potentially clearer naming:
- They can set their own error messaging.
- They can raise implementation errors rather than potentially hiding those types of errors based on a specific boolean answer.
- Provider developers can implement their own implementations without needing to reimplement something like
ExpectKnownValue
I hesitate to say going down the fully-type-named interfaces, e.g.
type BoolCheck interface {
CheckBool(value bool) error
String() string
}
Unless we'd consider passing json.Number
directly for number checks. We could still have nice "int64", etc implementations even if we did that though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the idea of returning an error and the additional flexibility that this yields.
In terms of the fully-type-named interfaces, I'm wondering about the internals. For instance, implementation of CheckBool()
would be straightforward:
func (v BoolValue) CheckBool(other bool) error {
if other != v.value {
return fmt.Errorf("%t does not equal %t", other, v.value)
}
return nil
}
With the call to CheckBool()
from expectKnownValue.CheckPlan()
looking something like the following:
switch reflect.TypeOf(result).Kind() {
case reflect.Bool:
v, ok := e.knownValue.(knownvalue.BoolValue)
if !ok {
resp.Error = fmt.Errorf("wrong type: attribute value is bool, known value type is %T", e.knownValue)
return
}
err := v.CheckBool(result.(bool))
if err != nil {
resp.Error = fmt.Errorf("attribute value: %v does not equal expected value: %s", result, v)
return
}
However, the internals for all of the KnownValue
types that deal with maps, or slices, would, I believe, require some internal type assertion to determine whether the fully-type-named interface methods could be called. For example:
func (v ObjectValue) CheckObject(other map[string]any) error {
if len(other) != len(v.value) {
return fmt.Errorf("%v does not equal %v", other, v.value)
}
for k, v := range v.value {
otherItem, ok := other[k]
if !ok {
return fmt.Errorf("%s is missing from %v", k, other)
}
switch reflect.TypeOf(otherItem).Kind() {
case reflect.Bool:
boolCheck, ok := v.(BoolValue)
if !ok {
return fmt.Errorf("wrong type: %T, known value type is %T", otherItem, v)
}
if err := boolCheck.CheckBool(otherItem.(bool)); err != nil {
return fmt.Errorf("%v does not equal %v", otherItem, v)
}
// Reflection logic for reflect.Map, reflect.Slice, reflect.String
default:
return fmt.Errorf("unrecognised type: %T, known value type is %T", otherItem, v)
}
}
return nil
}
I'll go ahead and implement the Check
interface, and perhaps we can go from there?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In that situation, yes, I would expect the collection/object checks to need to figure out what their element or per-object-attribute check type is. Another option would be to not support that functionality and leave it up to developer implementations to figure out what to do with making more generic collection/object checks that support elements/object attributes, but I think there's a lot of value in being able to offer that out of the box.
Not sure if it will make the logic easier/harder, but another way of doing this is performing a type switch on the v.value
(presumably the per-object-attribute checks in the given example code) and raising an error if the otherItem
doesn't conform to the given check type. This can help make sure the error messaging is tailored for the what the developer put into their object check definition rather than tailored for the data returned from Terraform, which without additional context can be harder to reason about in my opinion. e.g.
// note also including the object attribute name (k from example) and not assuming
// it was an equality check error in error messaging (the checks can say so
// themselves, amongst other possible errors :) )
switch check := v.(type) {
case BoolValue:
otherValue, ok := otherItem.(bool)
if !ok {
return fmt.Errorf("%s object attribute: expected bool value for BoolValue check, got: %T", k, otherItem)
}
if err := check.CheckBool(otherValue); err != nil {
return fmt.Errorf("%s object attribute: %s", k, err)
}
// ...
default:
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess a potential wrinkle here is, what if there is a dynamically typed value is being checked? Hmm. I would normally presume the value should be statically typed based on the given Terraform configuration. If not, we would be fine as long as there is some sort of escape hatch for a "I don't know the value type and I'll do all the work to figure it out" type of check, e.g. CheckDynamic(value any) error
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've refactored to use the Check
interface:
type Check interface {
CheckValue(value any) error
String() string
}
Before proceeding down the route of using fully-type-named interface, thought it might be worth considering how the ExpectKnownValue
, ExpectKnownOutputValue
, and ExpectKnownOutputValueAtPath
would handle these fully-type-named interfaces.
Currently, taking ExpectKnownValue
for instance, the type and constructor look as follows:
var _ PlanCheck = expectKnownValue{}
type expectKnownValue struct {
resourceAddress string
attributePath tfjsonpath.Path
knownValue knownvalue.Check
}
func (e expectKnownValue) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) {
/* ... */
}
func ExpectKnownValue(resourceAddress string, attributePath tfjsonpath.Path, knownValue knownvalue.Check) PlanCheck {
return expectKnownValue{
resourceAddress: resourceAddress,
attributePath: attributePath,
knownValue: knownValue,
}
}
As an example, BoolValue
currently implements the Check
interface:
var _ Check = BoolValue{}
type BoolValue struct {
value bool
}
func (v BoolValue) CheckValue(other any) error {
/* ... */
}
/* ... */
func BoolValueExact(value bool) BoolValue {
return BoolValue{
value: value,
}
}
If we refactor BoolValue
to implement the BoolCheck
interface:
type BoolCheck interface {
CheckBool(value bool) error
String() string
}
var _ BoolCheck = BoolValue{}
type BoolValue struct {
value bool
}
func (v BoolValue) CheckBool(value bool) error {
/* ... */
}
/* ... */
func BoolValueExact(value bool) BoolValue {
return BoolValue{
value: value,
}
}
Using fully-type-named interfaces will require that we reconsider how the constructor and struct field holding the checks will be handled for ExpectKnownValue
, ExpectKnownOutputValue
, and ExpectKnownOutputValueAtPath
, as we'll no longer be able to construct an ExpectKnownValue
plan check, for instance, using the following:
plancheck.ExpectKnownValue(
"test_resource.one",
tfjsonpath.New("bool_attribute"),
knownvalue.BoolValueExact(true),
),
knownvalue/object_partial.go
Outdated
// NewObjectValuePartial returns a KnownValue for asserting partial equality between the | ||
// supplied map[string]KnownValue and the value passed to the Equal method. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe its obvious, but in case it might not be, it might be good to talk about the "partial equality" a little bit more for the "Partial" implementations, e.g. in this case that the map keys are intended to be object attribute names and that only those attribute values are checked.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have added some further clarification around "partial equality". Let me know if you think we need more here.
}) | ||
``` | ||
|
||
## Known Value Types |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I love these tables but I wonder if we should consider laying this out a little differently based first on the type system types, then each type's available checks, instead of being based on the kind of check (e.g. exact vs partial vs whatever). I don't think we should be shy about making separate pages since there is a lot of good/relevant content.
One potential way to do this would be move this page to known-value(-checks)/index.mdx
and treat it as an overview page where the tables then link to checks available for each type system type, e.g. known-value(-checks)/list.mdx
. That page could then walk through the available list type checks, e.g. exact, partial, number of elements, etc. A string page could walk through the available string type checks, e.g. exact, regular expression, etc. A new custom page could walk through how developers can implement their own.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have refactored, splitting out by Known Value Check
type.
Also added a Custom Known Value Check
page. Thought I'd hold off expanding on this until we finalise the interface(s) we're going to use.
A consequence of allowing custom known value checks is that the internal logic within ExpectKnownValue
, ExpectKnownOutputValue
, and ExpectKnownOutputValueAtPath
should be able to handle any known value checks that fill the Check
interface. The internals of the the Check
function for these three types has therefore been refactored to provide this flexibility.
…ue, ExpectKnownOutputValue and ExpectKnownOutputValueAtPath (#243)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent work! Other than some minor quibbles over exporting less things upfront and the forever naming bikeshedding, this looks good to me 🚀 Excited to see what developers build with the interface.
knownvalue/bool.go
Outdated
|
||
// BoolValue is a Check for asserting equality between the value supplied | ||
// to BoolValueExact and the value passed to the CheckValue method. | ||
type BoolValue struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two questions:
- Should we leave these Check implementations as unexported except for the functions (e.g.
boolValue
here)? It'll clean up the public Go package documentation nicely and I'm not sure if there is anything that should need to reference/extend the implementation directly since its value field is unexported. - If doing that, maybe these names can then match the exported functions? e.g.
boolValueExact
for some additional clarity while reading the code
We can always export additional things in the future if we need! 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for reducing the amount of exported types 👍🏻
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good to me.
The known value check types have all be modified so that they are camel-cased versions of the exported functions, for example:
boolValueExact
=>BoolValueExact
listElementsExact =>
ListElementsExact`listValueExact
=>ListValueExact
listValuePartial
=>ListValuePartial
(note that the constructor for the know value partial types has changed from<List|Map|Object|Set>ValuePartialMatch
to<List|Map|Object|Set>ValuePartial
)
knownvalue/bool.go
Outdated
otherVal, ok := other.(bool) | ||
|
||
if !ok { | ||
return fmt.Errorf("expected bool value for BoolValue check, got: %T", other) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Followup to above: If the only exported thing is the function, then these errors here and in others can reference the function name (BoolValueExact
) which is super nice since that's how developers will be familiar with them 😄
|
||
// equateErrorMessage reports errors to be equal if both are nil | ||
// or both have the same message. | ||
var equateErrorMessage = cmp.Comparer(func(x, y error) bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sometimes I wish this was natively in go-cmp 😂
knownvalue/list_elements.go
Outdated
|
||
// ListElementsExact returns a Check for asserting that | ||
// a list num elements. | ||
func ListElementsExact(num int) ListElements { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
❤️ having room for things like ListElementsAtLeast, etc. which is the important part here. Not sure if folks might be more familiar with "size" over "elements" as a synonymous with "element count" though, since for example, we name these validators SizeAtLeast, etc. Just wanted to mention, not necessarily say it has to be that way or anything.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Totally happy to refactor to <List|Map|Set>SizeExact
. Having some level of parity with the validators seems beneficial to me.
Only question I have if we go this route is the naming for the object
equivalent - ObjectNumAttributesExact
, ObjectLenAttributesExact
, ObjectAttributesCountExact
.........
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, for objects I would argue that its a little awkward to count their attributes since that's part of their static type definition. This differs from map elements where there could be an arbitrary amount from an API.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have renamed to <List|Map|Set>SizeExact
, and removed ObjectAttributesExact
.
knownvalue/number.go
Outdated
// NumberValueExact returns a Check for asserting equality between the | ||
// supplied *big.Float and the value passed to the CheckValue method. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if it might be good to call out the 512-bit precision here too 😄
reflect.Slice, | ||
reflect.String: | ||
if err := e.knownValue.CheckValue(result); err != nil { | ||
resp.Error = err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should these errors include the path string as well for developers?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added a String()
method to tfjson.Path
for this purpose.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome, thanks!
plancheck/expect_known_value.go
Outdated
reflect.Slice, | ||
reflect.String: | ||
if err := e.knownValue.CheckValue(result); err != nil { | ||
resp.Error = err |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similarly here regarding the path string
func TestExpectKnownValue_CheckPlan_Bool(t *testing.T) { | ||
t.Parallel() | ||
|
||
r.Test(t, r.TestCase{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd suggest using resource
written out in these examples to match the import without aliasing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had a question about the naming scheme below, but not a hard stance by any means. All of this code looks awesome!
knownvalue/known_value.go
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
super nit 🦸🏻 - Should this file be named check.go
now?
knownvalue/bool.go
Outdated
|
||
// BoolValue is a Check for asserting equality between the value supplied | ||
// to BoolValueExact and the value passed to the CheckValue method. | ||
type BoolValue struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for reducing the amount of exported types 👍🏻
knownvalue/bool.go
Outdated
func BoolValueExact(value bool) BoolValue { | ||
return BoolValue{ | ||
value: value, | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wanted to bring up the first thing I thought of with the naming. Thinking there may be some context that I'm missing that's important to the explicitness of the naming.
Is there a benefit in duplicating the Value
in some of these implementations? Since it's already in the package name as knownvalue.BoolValueExact()
vs. knownvalue.BoolExact()
. Does this naming scheme break down/not make sense in certain use-cases?
Some examples:
knownvalue.Null()
knownvalue.ObjectPartial()
knownvalue.ObjectExact()
knownvalue.ListExact()
knownvalue.ListPartialMatch()
knownvalue.MapExact()
knownvalue.MapPartialMatch()
knownvalue.StringExact()
Leaving room for others that become clearer with the additional words, like:
knownvalue.ListElementsExact()
knownvalue.MapElementsExact()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm on board with this naming change, and potentially the change in names for the "Size" checks too.
Happy to go with consensus here. What are your thoughts @bflad ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm +1 for removing repeated words for when folks need to use something, so I'm +1 for removing Value
out of the naming in the knownvalue
package.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have refactored. Thanks for the suggestion.
Co-authored-by: Brian Flad <bflad417@gmail.com>
…243' into bendbennett/issues-243
I'm going to lock this pull request because it has been closed for 30 days ⏳. This helps our maintainers find and focus on the active contributions. |
Closes: #243
Further Considerations
There are two related PRs open on terraform-json, and terraform-exec which, if they proceed, would allow for checking of numerical values in the plan usingjson.Number
. The current PR will be amended if the PRs on terraform-json, and terraform-exec are merged and released.plancheck.ExpectStringOutputValue(name string, value string)
, and similar. Do we want to add those to this PR, add an issue and implement later, or wait for an indication of community interest/utility?