From 65ec56d254aaa3e221933e911aa1540f1e93bd00 Mon Sep 17 00:00:00 2001 From: Onsi Fakhouri Date: Tue, 26 Dec 2023 14:31:31 -0700 Subject: [PATCH] Introduce DescribeTableSubtree --- docs/index.md | 105 +++++++++++++++++- dsl/table/table_dsl.go | 9 +- internal/internal_integration/table_test.go | 47 ++++++++ table_dsl.go | 113 ++++++++++++++++---- types/errors.go | 9 ++ 5 files changed, 260 insertions(+), 23 deletions(-) diff --git a/docs/index.md b/docs/index.md index 8764b16e6..9f09075fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1378,7 +1378,7 @@ DescribeTable("Extracting the author's first and last name", You'll be notified with a clear message at runtime if the parameter types don't match the spec closure signature. #### Mental Model: Table Specs are just Syntactic Sugar -`DescribeTable` is simply providing syntactic sugar to convert its Ls into a set of standard Ginkgo nodes. During the [Tree Construction Phase](#mental-model-how-ginkgo-traverses-the-spec-hierarchy) `DescribeTable` is generating a single container node that contains one subject node per table entry. The description for the container node will be the description passed to `DescribeTable` and the descriptions for the subject nodes will be the descriptions passed to the `Entry`s. During the Run Phase, when specs run, each subject node will simply invoke the spec closure passed to `DescribeTable`, passing in the parameters associated with the `Entry`. +`DescribeTable` is simply providing syntactic sugar to convert its entries into a set of standard Ginkgo nodes. During the [Tree Construction Phase](#mental-model-how-ginkgo-traverses-the-spec-hierarchy) `DescribeTable` is generating a single container node that contains one subject node per table entry. The description for the container node will be the description passed to `DescribeTable` and the descriptions for the subject nodes will be the descriptions passed to the `Entry`s. During the Run Phase, when specs run, each subject node will simply invoke the spec closure passed to `DescribeTable`, passing in the parameters associated with the `Entry`. To put it another way, the table test above is equivalent to: @@ -1629,6 +1629,86 @@ var _ = Describe("Math", func() { Will generate entries named: `1 + 2 = 3`, `-1 + 2 = 1`, `zeros`, `110 = 10 + 100`, and `7 = 7`. +#### Generating Subtree Tables + +As we've seen `DescribeTable` takes a function and interprets it as the body of a single `It` function. Sometimes, however, you may want to run a collection of specs for a given table entry. You can do this with `DescribeTableSubtree`: + +```go +DescribeTableSubtree("handling requests", + func(url string, code int, message string) { + var resp *http.Response + BeforeEach(func() { + var err error + resp, err = http.Get(url) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(resp.Body.Close) + }) + + It("should return the expected status code", func() { + Expect(resp.StatusCode).To(Equal(code)) + }) + + It("should return the expected message", func() { + body, err := ioutil.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(Equal(message)) + }) + }, + Entry("default response", "example.com/response", http.StatusOK, "hello world"), + Entry("missing response", "example.com/missing", http.StatusNotFound, "wat?"), + ... +) +``` + +now the body function passed to the table is invoked during the Tree Construction Phase to generate a set of specs for each entry. Each body function is invoked within the context of a new container so that setup nodes will only run for the specs defined in the body function. As with `DescribeTable` this is simply synctactic sugar around Ginkgo's existing DSL. The above example is identical to: + +```go + +Describe("handling requests", func() { + Describe("default response", func() { + var resp *http.Response + BeforeEach(func() { + var err error + resp, err = http.Get("example.com/response") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(resp.Body.Close) + }) + + It("should return the expected status code", func() { + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + }) + + It("should return the expected message", func() { + body, err := ioutil.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(Equal("hello world")) + }) + }) + + Describe("missing response", func() { + var resp *http.Response + BeforeEach(func() { + var err error + resp, err = http.Get("example.com/missing") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(resp.Body.Close) + }) + + It("should return the expected status code", func() { + Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) + }) + + It("should return the expected message", func() { + body, err := ioutil.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(Equal("wat?")) + }) + }) +}) +``` + +all the infrastructure around generating table entry descriptions applies here as well - though the description will be the title of the generatd container. Note that you **must** add subject nodes in the body function if you want `DescribeHandleSubtree` to add specs. + ### Alternatives to Dot-Importing Ginkgo As shown throughout this documentation, Ginkgo users are encouraged to dot-import the Ginkgo DSL into their test suites to effectively extend the Go language with Ginkgo's expressive building blocks: @@ -4127,6 +4207,29 @@ DescribeTable("Reading invalid books always errors", func(book *books.Book) { ``` +alternatively you can use `DescribeTableSubtree` to associate multiple specs with a given entry: + +```go +DescribeTableSubtree("Handling invalid books", func(book *books.Book) { + Describe("Storing invalid books", func() { + It("always errors", func() { + Expect(library.Store(book)).To(MatchError(books.ErrInvalidBook)) + }) + }) + + Describe("Reading invalid books", func() { + It("always errors", func() { + Expect(user.Read(book)).To(MatchError(books.ErrInvalidBook)) + }) + }) + }, + Entry("Empty book", &books.Book{}), + Entry("Only title", &books.Book{Title: "Les Miserables"}), + Entry("Only author", &books.Book{Author: "Victor Hugo"}), + Entry("Missing pages", &books.Book{Title: "Les Miserables", Author: "Victor Hugo"}) +) +``` + ### Patterns for Asynchronous Testing It is common, especially in integration suites, to be testing behaviors that occur asynchronously (either within the same process or, in the case of distributed systems, outside the current test process in some combination of external systems). Ginkgo and Gomega provide the building blocks you need to write effective asynchronous specs efficiently. diff --git a/dsl/table/table_dsl.go b/dsl/table/table_dsl.go index cf7c8bd3e..9bbfb8284 100644 --- a/dsl/table/table_dsl.go +++ b/dsl/table/table_dsl.go @@ -1,7 +1,7 @@ /* -Ginkgo isusually dot-imported via: +Ginkgo is usually dot-imported via: - import . "github.com/onsi/ginkgo/v2" + import . "github.com/onsi/ginkgo/v2" however some parts of the DSL may conflict with existing symbols in the user's code. @@ -23,6 +23,11 @@ var FDescribeTable = ginkgo.FDescribeTable var PDescribeTable = ginkgo.PDescribeTable var XDescribeTable = ginkgo.XDescribeTable +var DescribeTableSubtree = ginkgo.DescribeTableSubtree +var FDescribeTableSubtree = ginkgo.FDescribeTableSubtree +var PDescribeTableSubtree = ginkgo.PDescribeTableSubtree +var XDescribeTableSubtree = ginkgo.XDescribeTableSubtree + type TableEntry = ginkgo.TableEntry var Entry = ginkgo.Entry diff --git a/internal/internal_integration/table_test.go b/internal/internal_integration/table_test.go index a695ec3c4..bf954bb63 100644 --- a/internal/internal_integration/table_test.go +++ b/internal/internal_integration/table_test.go @@ -44,6 +44,53 @@ var _ = Describe("Table driven tests", func() { }) }) + Describe("constructing subtree tables", func() { + BeforeEach(func() { + success, _ := RunFixture("table subtree happy-path", func() { + DescribeTableSubtree("hello", func(a, b, sum, difference int) { + var actualSum, actualDifference int + BeforeEach(func() { + rt.Run(CurrentSpecReport().ContainerHierarchyTexts[1] + " bef") + actualSum = a + b + actualDifference = a - b + }) + It(fmt.Sprintf("%d + %d sums correctly", a, b), func() { + rt.Run(CurrentSpecReport().ContainerHierarchyTexts[1] + " sum") + if actualSum != sum { + F("fail") + } + }) + It(fmt.Sprintf("%d - %d subtracts correctly", a, b), func() { + rt.Run(CurrentSpecReport().ContainerHierarchyTexts[1] + " difference") + if actualDifference != difference { + F("fail") + } + }) + }, func(a, b, sum, differenct int) string { return fmt.Sprintf("%d,%d", a, b) }, + Entry(nil, 1, 1, 2, 0), + Entry(nil, 1, 2, 3, -1), + Entry(nil, 2, 1, 0, 0), + ) + }) + Ω(success).Should(BeFalse()) + }) + + It("runs all the entries", func() { + Ω(rt).Should(HaveTracked("1,1 bef", "1,1 sum", "1,1 bef", "1,1 difference", "1,2 bef", "1,2 sum", "1,2 bef", "1,2 difference", "2,1 bef", "2,1 sum", "2,1 bef", "2,1 difference")) + }) + + It("reports on the tests correctly", func() { + Ω(reporter.Did.Names()).Should(Equal([]string{"1 + 1 sums correctly", "1 - 1 subtracts correctly", "1 + 2 sums correctly", "1 - 2 subtracts correctly", "2 + 1 sums correctly", "2 - 1 subtracts correctly"})) + Ω(reporter.Did.Find("1 + 1 sums correctly")).Should(HavePassed()) + Ω(reporter.Did.Find("1 - 1 subtracts correctly")).Should(HavePassed()) + Ω(reporter.Did.Find("1 + 2 sums correctly")).Should(HavePassed()) + Ω(reporter.Did.Find("1 - 2 subtracts correctly")).Should(HavePassed()) + Ω(reporter.Did.Find("2 + 1 sums correctly")).Should(HaveFailed("fail", types.NodeTypeIt)) + Ω(reporter.Did.Find("2 - 1 subtracts correctly")).Should(HaveFailed("fail", types.NodeTypeIt)) + Ω(reporter.End).Should(BeASuiteSummary(false, NSpecs(6), NPassed(4), NFailed(2))) + }) + }) + Describe("Entry Descriptions", func() { Describe("tables with no table-level entry description functions or strings", func() { BeforeEach(func() { diff --git a/table_dsl.go b/table_dsl.go index ac9b7abb5..a3aef821b 100644 --- a/table_dsl.go +++ b/table_dsl.go @@ -46,7 +46,7 @@ And can explore some Table patterns here: https://onsi.github.io/ginkgo/#table-s */ func DescribeTable(description string, args ...interface{}) bool { GinkgoHelper() - generateTable(description, args...) + generateTable(description, false, args...) return true } @@ -56,7 +56,7 @@ You can focus a table with `FDescribeTable`. This is equivalent to `FDescribe`. func FDescribeTable(description string, args ...interface{}) bool { GinkgoHelper() args = append(args, internal.Focus) - generateTable(description, args...) + generateTable(description, false, args...) return true } @@ -66,7 +66,7 @@ You can mark a table as pending with `PDescribeTable`. This is equivalent to `P func PDescribeTable(description string, args ...interface{}) bool { GinkgoHelper() args = append(args, internal.Pending) - generateTable(description, args...) + generateTable(description, false, args...) return true } @@ -75,6 +75,71 @@ You can mark a table as pending with `XDescribeTable`. This is equivalent to `X */ var XDescribeTable = PDescribeTable +/* +DescribeTableSubtree describes a table-driven spec that generates a set of tests for each entry. + +For example: + + DescribeTableSubtree("a subtree table", + func(url string, code int, message string) { + var resp *http.Response + BeforeEach(func() { + var err error + resp, err = http.Get(url) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(resp.Body.Close) + }) + + It("should return the expected status code", func() { + Expect(resp.StatusCode).To(Equal(code)) + }) + + It("should return the expected message", func() { + body, err := ioutil.ReadAll(resp.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(string(body)).To(Equal(message)) + }) + }, + Entry("default response", "example.com/response", http.StatusOK, "hello world"), + Entry("missing response", "example.com/missing", http.StatusNotFound, "wat?"), + ) + +Note that you **must** place define an It inside the body function. + +You can learn more about DescribeTableSubtree here: https://onsi.github.io/ginkgo/#table-specs +And can explore some Table patterns here: https://onsi.github.io/ginkgo/#table-specs-patterns +*/ +func DescribeTableSubtree(description string, args ...interface{}) bool { + GinkgoHelper() + generateTable(description, true, args...) + return true +} + +/* +You can focus a table with `FDescribeTableSubtree`. This is equivalent to `FDescribe`. +*/ +func FDescribeTableSubtree(description string, args ...interface{}) bool { + GinkgoHelper() + args = append(args, internal.Focus) + generateTable(description, true, args...) + return true +} + +/* +You can mark a table as pending with `PDescribeTableSubtree`. This is equivalent to `PDescribe`. +*/ +func PDescribeTableSubtree(description string, args ...interface{}) bool { + GinkgoHelper() + args = append(args, internal.Pending) + generateTable(description, true, args...) + return true +} + +/* +You can mark a table as pending with `XDescribeTableSubtree`. This is equivalent to `XDescribe`. +*/ +var XDescribeTableSubtree = PDescribeTableSubtree + /* TableEntry represents an entry in a table test. You generally use the `Entry` constructor. */ @@ -131,14 +196,14 @@ var XEntry = PEntry var contextType = reflect.TypeOf(new(context.Context)).Elem() var specContextType = reflect.TypeOf(new(SpecContext)).Elem() -func generateTable(description string, args ...interface{}) { +func generateTable(description string, isSubtree bool, args ...interface{}) { GinkgoHelper() cl := types.NewCodeLocation(0) containerNodeArgs := []interface{}{cl} entries := []TableEntry{} - var itBody interface{} - var itBodyType reflect.Type + var internalBody interface{} + var internalBodyType reflect.Type var tableLevelEntryDescription interface{} tableLevelEntryDescription = func(args ...interface{}) string { @@ -166,11 +231,11 @@ func generateTable(description string, args ...interface{}) { case t.Kind() == reflect.Func && t.NumOut() == 1 && t.Out(0) == reflect.TypeOf(""): tableLevelEntryDescription = arg case t.Kind() == reflect.Func: - if itBody != nil { + if internalBody != nil { exitIfErr(types.GinkgoErrors.MultipleEntryBodyFunctionsForTable(cl)) } - itBody = arg - itBodyType = reflect.TypeOf(itBody) + internalBody = arg + internalBodyType = reflect.TypeOf(internalBody) default: containerNodeArgs = append(containerNodeArgs, arg) } @@ -200,39 +265,47 @@ func generateTable(description string, args ...interface{}) { err = types.GinkgoErrors.InvalidEntryDescription(entry.codeLocation) } - itNodeArgs := []interface{}{entry.codeLocation} - itNodeArgs = append(itNodeArgs, entry.decorations...) + internalNodeArgs := []interface{}{entry.codeLocation} + internalNodeArgs = append(internalNodeArgs, entry.decorations...) hasContext := false - if itBodyType.NumIn() > 0. { - if itBodyType.In(0).Implements(specContextType) { + if internalBodyType.NumIn() > 0. { + if internalBodyType.In(0).Implements(specContextType) { hasContext = true - } else if itBodyType.In(0).Implements(contextType) && (len(entry.parameters) == 0 || !reflect.TypeOf(entry.parameters[0]).Implements(contextType)) { + } else if internalBodyType.In(0).Implements(contextType) && (len(entry.parameters) == 0 || !reflect.TypeOf(entry.parameters[0]).Implements(contextType)) { hasContext = true } } if err == nil { - err = validateParameters(itBody, entry.parameters, "Table Body function", entry.codeLocation, hasContext) + err = validateParameters(internalBody, entry.parameters, "Table Body function", entry.codeLocation, hasContext) } if hasContext { - itNodeArgs = append(itNodeArgs, func(c SpecContext) { + internalNodeArgs = append(internalNodeArgs, func(c SpecContext) { if err != nil { panic(err) } - invokeFunction(itBody, append([]interface{}{c}, entry.parameters...)) + invokeFunction(internalBody, append([]interface{}{c}, entry.parameters...)) }) + if isSubtree { + exitIfErr(types.GinkgoErrors.ContextsCannotBeUsedInSubtreeTables(cl)) + } } else { - itNodeArgs = append(itNodeArgs, func() { + internalNodeArgs = append(internalNodeArgs, func() { if err != nil { panic(err) } - invokeFunction(itBody, entry.parameters) + invokeFunction(internalBody, entry.parameters) }) } - pushNode(internal.NewNode(deprecationTracker, types.NodeTypeIt, description, itNodeArgs...)) + internalNodeType := types.NodeTypeIt + if isSubtree { + internalNodeType = types.NodeTypeContainer + } + + pushNode(internal.NewNode(deprecationTracker, internalNodeType, description, internalNodeArgs...)) } }) diff --git a/types/errors.go b/types/errors.go index 4fbdc3e9b..6bb72d00c 100644 --- a/types/errors.go +++ b/types/errors.go @@ -505,6 +505,15 @@ func (g ginkgoErrors) IncorrectVariadicParameterTypeToTableFunction(expected, ac } } +func (g ginkgoErrors) ContextsCannotBeUsedInSubtreeTables(cl CodeLocation) error { + return GinkgoError{ + Heading: "Contexts cannot be used in subtree tables", + Message: "You''ve defined a subtree body function that accepts a context but did not provide one in the table entry. Ginkgo SpecContexts can only be passed in to subject and setup nodes - so if you are trying to implement a spec timeout you should request a context in the It function within your subtree body function, not in the subtree body function itself.", + CodeLocation: cl, + DocLink: "table-specs", + } +} + /* Parallel Synchronization errors */ func (g ginkgoErrors) AggregatedReportUnavailableDueToNodeDisappearing() error {