pkg/approvaltest/approvals.go (152 lines of code) (raw):

// Licensed to Elasticsearch B.V. under one or more contributor // license agreements. See the NOTICE file distributed with // this work for additional information regarding copyright // ownership. Elasticsearch B.V. licenses this file to you 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 approvaltest contains helper functions to compare and assert // the received content of a test vs the accepted. package approvaltest import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/tidwall/gjson" "github.com/tidwall/sjson" "github.com/elastic/apm-tools/pkg/espoll" ) const ( // ApprovedSuffix signals a file has been reviewed and approved. ApprovedSuffix = ".approved.json" // ReceivedSuffix signals a file has changed and not yet been approved. ReceivedSuffix = ".received.json" ) // ApproveEvents compares the _source of the search hits with the // contents of the file in "approvals/<name>.approved.json". // // Dynamic fields (@timestamp, observer.id, etc.) are replaced // with a static string for comparison. Integration tests elsewhere // use canned data to test fields that we do not cover here. // // If the events differ, then the test will fail. func ApproveEvents(t testing.TB, name string, hits []espoll.SearchHit, dynamic ...string) { t.Helper() sources := make([][]byte, len(hits)) for i, hit := range hits { sources[i] = hit.RawSource } rewriteDynamic(t, sources, false, dynamic...) // Rewrite dynamic fields and sort them for repeatable diffs. sort.Slice(sources, func(i, j int) bool { return compareDocumentFields(sources[i], sources[j]) < 0 }) approveEventDocs(t, filepath.Join("approvals", name), sources) } // ApproveFields compares the fields of the search hits with the // contents of the file in "approvals/<name>.approved.json". // // Dynamic fields (@timestamp, observer.id, etc.) are replaced // with a static string for comparison. Integration tests elsewhere // use canned data to test fields that we do not cover here. // // TODO(axw) eventually remove ApproveEvents when we have updated // all calls to use ApproveFields. ApproveFields should be used // since it includes runtime fields, whereas ApproveEvents only // looks at _source. func ApproveFields(t testing.TB, name string, hits []espoll.SearchHit, dynamic ...string) { t.Helper() fields := make([][]byte, len(hits)) for i, hit := range hits { fields[i] = hit.RawFields } // Rewrite dynamic fields and sort them for repeatable diffs. rewriteDynamic(t, fields, true, dynamic...) sort.Slice(fields, func(i, j int) bool { return compareDocumentFields(fields[i], fields[j]) < 0 }) approveFields(t, filepath.Join("approvals", name), fields) } // rewriteDynamic rewrites all dynamic fields to have a known value, so dynamic // fields don't affect diffs. The flattenedKeys parameter defines how the // field should be queried in the source, if flattenedKeys is passed as true // then the source will be queried for the dynamic fields as flattened keys. func rewriteDynamic(t testing.TB, srcs [][]byte, flattenedKeys bool, dynamic ...string) { t.Helper() // Fields generated by the server (e.g. observer.*) // agent which may change between tests. // // Ignore their values in comparisons, but compare // existence: either the field exists in both, or neither. dynamic = append([]string{ "ecs.version", "event.ingested", "observer.ephemeral_id", "observer.hostname", "observer.id", "observer.version", }, dynamic...) for i := range srcs { for _, field := range dynamic { if flattenedKeys { field = strings.ReplaceAll(field, ".", "\\.") } existing := gjson.GetBytes(srcs[i], field) if !existing.Exists() { continue } var v interface{} if existing.IsArray() { v = []any{"dynamic"} } else { v = "dynamic" } var err error srcs[i], err = sjson.SetBytes(srcs[i], field, v) if err != nil { t.Fatal(err) } } } } // approveEventDocs compares the given event documents with // the contents of the file in "<name>.approved.json". // // Any specified dynamic fields (e.g. @timestamp, observer.id) // will be replaced with a static string for comparison. // // If the events differ, then the test will fail. func approveEventDocs(t testing.TB, name string, eventDocs [][]byte) { t.Helper() events := make([]interface{}, len(eventDocs)) for i, doc := range eventDocs { var event map[string]interface{} if err := json.Unmarshal(doc, &event); err != nil { t.Fatal(err) } events[i] = event } received := map[string]interface{}{"events": events} approve(t, name, received) } func approveFields(t testing.TB, name string, docs [][]byte) { t.Helper() // Rewrite all dynamic fields to have a known value, // so dynamic fields don't affect diffs. decodedDocs := make([]any, len(docs)) for i, doc := range docs { var fields map[string]any if err := json.Unmarshal(doc, &fields); err != nil { t.Fatal(err) } decodedDocs[i] = fields } approve(t, name, decodedDocs) } // approve compares the given value with the contents of the file // "<name>.approved.json". // // If the value differs, then the test will fail. func approve(t testing.TB, name string, received interface{}) { t.Helper() var approved interface{} if err := readApproved(name, &approved); err != nil { t.Fatalf("failed to read approved file: %v", err) } if diff := cmp.Diff(approved, received); diff != "" { if err := writeReceived(name, received); err != nil { t.Fatalf("failed to write received file: %v", err) } t.Fatalf("%s\n%s\n\n", diff, "Test failed. Run `make check-approvals` to verify the diff.", ) } else { // Remove an old *.received.json file if it exists, ignore errors _ = removeReceived(name) } } func readApproved(name string, approved interface{}) error { path := name + ApprovedSuffix f, err := os.Open(path) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to open approved file for %s: %w", name, err) } defer f.Close() if os.IsNotExist(err) { return nil } if err := json.NewDecoder(f).Decode(&approved); err != nil { return fmt.Errorf("failed to decode approved file for %s: %w", name, err) } return nil } func removeReceived(name string) error { return os.Remove(name + ReceivedSuffix) } func writeReceived(name string, received interface{}) error { fullpath := name + ReceivedSuffix if err := os.MkdirAll(filepath.Dir(fullpath), 0755); err != nil { return fmt.Errorf("failed to create directories for received file: %w", err) } f, err := os.Create(fullpath) if err != nil { return fmt.Errorf("failed to create received file for %s: %w", name, err) } defer f.Close() enc := json.NewEncoder(f) enc.SetIndent("", " ") if err := enc.Encode(received); err != nil { return fmt.Errorf("failed to encode received file for %s: %w", name, err) } return nil }