Simplifier les tests d’écriture
Au travail, nous utilisions gomega
pour tester. Ça marchait bien jusqu’au jour où ça devenait trop verbeux et goconvey
est devenu un meilleur ajustement. Pour empêcher le référentiel d’utiliser deux ensembles différents de fonctions d’assertion, j’ai décidé d’écrire un adaptateur pour faire goconvey
travailler avec gomega
fonctions d’assertion.
gomega
est un framework de test pour Go. Voici à quoi ça ressemble :
package example_testimport (
"fmt"
"testing"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func Test(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Example Suite")
}
var _ = Describe("PathTraversal", func() {
var s string
BeforeEach(func() {
s = "Start"
})
AfterEachfunc(func() {
fmt.Println(s)
})
Describe("Executing test 1", func() {
BeforeEach(func() {
s += " -> test1"
})
It("Test 1.1:", func() {
s += " -> test1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1"))
})
It("Test 1.2:", func() {
s += " -> test1.2"
Ω(s).To(Equal("Start -> test1 -> test1.2"))
})
})
Describe("Execute test 2", func() {
BeforeEach(func() {
s += " -> test2"
})
It("Test 2.1:", func() {
s += " -> test2.1"
})
It("Test 2.2:", func() {
s += " -> test2.2"
Ω(s).To(Equal("Start -> test2 -> test2.2"))
})
})
})
gomega
/ginkgo utilise un DSL pour décrire les cas de test. Ω()
est un alias de Expect()
. L’ordre d’exécution est le suivant :
- Lors de la course
Test 1.1
il s’exécutera dans l’ordre suivant :
Describe("PathTraversal")
-> BeforeEach()
-> Describe("Executing test 1")
-> BeforeEach()
-> It("Test 1.1")
-> AfterEach() // print the output:
// Start -> test1 -> test1.1
- Lors de la course
Test 1.2
il s’exécutera dans l’ordre suivant :
Describe("PathTraversal")
-> BeforeEach()
-> Describe("Executing test 1")
-> BeforeEach()
-> It("Test 1.2")
-> AfterEach() // print the output:
// Start -> test1 -> test1.2
goconvey
est un autre framework de test pour Go. En plus d’écrire des tests, il dispose également d’un joli serveur Web pour afficher les résultats avec des rapports de couverture. Mais dans cet article, concentrons-nous uniquement sur la partie test. Voici à quoi ça ressemble :
package example_testimport (
"fmt"
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func Test(t *testing.T) {
Convey("PathTraversal", t, func() {
s := "Start"
defer func() {
fmt.Println(s)
}()
Convey("Executing test 1", func() {
s += " -> test1"
So(s, ShouldEqual, "Start -> test1")
Convey("Test 1.1:", func() {
s += " -> test1.1"
So(s, ShouldEqual, "Start -> test1 -> test1.1")
})
Convey("Test 1.2:", func() {
s += " -> test1.2"
So(s, ShouldEqual, "Start -> test1 -> test1.2")
})
})
Convey("Execute test 2", func() {
s += " -> test2"
So(s, ShouldEqual, "Start -> test2")
Convey("Test 2.1:", func() {
s += " -> test2.1"
})
Convey("Test 2.2:", func() {
s += " -> test2.2"
So(s, ShouldEqual, "Start -> test2 -> test2.2")
})
})
})
}
Semblable à la gomega
exemple, l’ordre d’exécution est :
- Lors de la course
Test 1.1
il s’exécutera dans l’ordre suivant :
Convey("PathTraversal")
-> Convey("Executing test 1")
-> Convey("Test 1.1")
-> defer func() // print the output:
// Start -> test1 -> test1.1
- Lors de la course
Test 1.2
il s’exécutera dans l’ordre suivant :
Convey("PathTraversal")
-> Convey("Execute test 1")
-> Convey("Test 1.2")
-> defer func() // print the output:
// Start -> test1 -> test1.2
D’après les deux exemples ci-dessus, ils sont assez similaires dans la structure de test et peuvent atteindre le même ordre d’exécution. Les deux peuvent sortir Start -> test1 -> test1.1\nStart -> test1 -> test1.2...
mais gomega
est plus verbeux avec le Describe
, BeforeEach
, AfterEach
les fonctions. D’autre part, goconvey
est plus concis et facile à lire. Mais ce n’était pas un problème pendant longtemps, et nous nous en occupions toujours avec plaisir.
Jusqu’au jour où nous avions besoin de tester une fonction avec de nombreuses branches d’exécution. Soudain, le code de test avec gomega
est devenu un gâchis.
Ajoutons un autre niveau avec deux sous-branches Test 1.1.1
et Test 1.1.2
au gomega
Exemple:
package example_testvar _ = Describe("PathTraversal", func() {
var s string
BeforeEach(func() {
s = "Start"
})
AfterEachfunc(func() {
fmt.Println(s)
})
Describe("Executing test 1", func() {
BeforeEach(func() {
s += " -> test1"
})
It("Test 1.1:", func() {
s += " -> test1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1"))
It("Test 1.1.1:", func() {
s += " -> test1.1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1 -> test1.1.1"))
})
It("Test 1.1.2:", func() {
s += " -> test1.1.2"
Ω(s).To(Equal("Start -> test1 -> test1.1 -> test1.1.2"))
})
})
/* ... */
})
/* ... */
})
Cela ne fonctionnera pas parce que gomega
n’autorise pas l’imbrication It()
. Nous devons refactoriser les tests pour que cela fonctionne, comme indiqué ci-dessous :
package example_testvar _ = Describe("PathTraversal", func() {
var s string
BeforeEach(func() {
s = "Start"
})
AfterEachfunc(func() {
fmt.Println(s)
})
Describe("Executing test 1", func() {
BeforeEach(func() {
s += " -> test1"
})
Context("Test 1.1:", func() {
BeforeEach(func() {
s += " -> test1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1"))
)
}
It("Test 1.1.1:", func() {
s += " -> test1.1.1"
Ω(s).To(Equal("Start -> test1 -> test1.1 -> test1.1.1"))
})
It("Test 1.1.1:", func() {
s += " -> test1.1.2"
Ω(s).To(Equal("Start -> test1 -> test1.1 -> test1.1.2"))
})
})
/* ... */
})
/* ... */
})
Dans le code du monde réel, imaginez que nous devons déplacer beaucoup de lignes dans et hors de It()
et BeforeEach()
. A chaque fois on veut ajouter un autre niveau de branche. Trop de lignes modifiées. C’est ennuyeux, sujet aux erreurs et difficile à modifier, à lire et à réviser.
Mais avec goconvey
, l’expérience est fluide. Nous devons ajouter un autre niveau de Convey()
, et les lignes existantes n’ont pas besoin d’être modifiées. Voici à quoi cela ressemble :
package example_testfunc Test(t *testing.T) {
Convey("PathTraversal", t, func() {
s := "Start"
defer func() {
fmt.Println(s)
}()
Convey("Executing test 1", func() {
s += " -> test1"
So(s, ShouldEqual, "Start -> test1")
Convey("Test 1.1:", func() {
s += " -> test1.1"
So(s, ShouldEqual, "Start -> test1 -> test1.1")
Convey("Test 1.1.1:", func() {
s += " -> test1.1.1"
So(s, ShouldEqual, "Start -> test1 -> test1.1 -> test1.1.1")
})
Convey("Test 1.1.2:", func() {
s += " -> test1.1.2"
So(s, ShouldEqual, "Start -> test1 -> test1.1 -> test1.1.2")
})
})
/* ... */
})
/* ... */
})
}
Mais nous voulons toujours utiliser gomega
c’est Ω()
les fonctions. Garder deux ensembles d’assertions dans notre code source sera déroutant, en particulier pour les nouvelles personnes. Faisons en sorte que ça marche avec goconvey
.
Voici à quoi ressemble le code final (la version complète peut être trouvée ici):
package xconveyimport (
// Workaround for ginkgo flags used in CI test commands. Without this import, ginkgo flags are not registered and
// the command "go test -v ./... -ginkgo.v" will fail. But for some other reason, ginkgo can not be imported from
// both test and non-test packages (error: flag redefined: ginkgo.seed). So test files using this package (xconvey)
// must NOT import ginkgo.
_ "github.com/onsi/ginkgo"
"github.com/onsi/gomega"
gomegatypes "github.com/onsi/gomega/types"
"github.com/smartystreets/goconvey/convey"
)
func Convey(items ...any) {
defer conveyGomegaSetup(items...)()
convey.Convey(items...)
}
func conveyGomegaSetup(items ...any) func() {
if len(items) >= 2 {
testT, ok := items[1].(*testing.T)
if ok {
gomega.Default = gomega.NewWithT(testT).
ConfigureWithFailHandler(func(message string, callerSkip ...int) {})
return func() {
/* clean up */
}
}
}
return func() {}
}
func Ωx(actual any, extra ...any) gomega.Assertion {
assertion := gomega.Expect(actual, extra...)
return conveyGomegaAssertion{actual: actual, assertion: assertion}
}
type conveyGomegaAssertion struct {
actual any
assertion gomega.Assertion
}
func (a conveyGomegaAssertion) To(matcher gomegatypes.GomegaMatcher, optionalDescription ...any) bool {
if len(optionalDescription) > 0 {
panic("optionalDescription is not supported")
}
convey.So(a.actual, func(_ any, _ ...any) string {
success, err := matcher.Match(a.actual)
if err != nil {
return fmt.Sprintf("ERROR: %v", err)
}
if !success {
return matcher.FailureMessage(a.actual)
}
return ""
})
return true
}
func (a conveyGomegaAssertion) ToNot(matcher gomegatypes.GomegaMatcher, optionalDescription ...any) bool {
if len(optionalDescription) > 0 {
panic("optionalDescription is not supported")
}
convey.So(a.actual, func(_ any, _ ...any) string {
success, err := matcher.Match(a.actual)
if err != nil {
return fmt.Sprintf("ERROR: %v", err)
}
if success {
return matcher.NegatedFailureMessage(a.actual)
}
return ""
})
return true
}
Et voici comment il est utilisé :
package example_testimport (
"fmt"
"testing"
. "github.com/onsi/gomega"
"github.com/example/xconvey"
)
func Test(t *testing.T) {
Ω := xconvey.Ωx // adapter to make goconvey work with gomega
xconvey.Convey("PathTraversal", t, func() {
s := "Start"
defer func() {
fmt.Println(s)
}()
xconvey.Convey("Executing test 1", func() {
s += " -> test1"
Ω(s).To(Equal("Start -> test1"))
})
/* ... */
})
}
xconvey.Convey()
est notre fonction wrapper pourconvey.Convey()
. Il appelleconveyGomegaSetup()
installergomega
avant d’appelerconvey.Convey()
.gomega
a besoin*testing.T
etFail()
à configurer avant utilisationΩ()
.goconvey
a besoin*testing.T
au plus haut niveauConvey()
. Notre fonctionconveyGomegaSetup()
détecte le niveau supérieurConvey()
puis met en place les choses nécessaires pourgomega
.Ωx()
est un emballage autourgomega.Expect()
. Il renvoie ungomega.Assertion
objet. On peut utiliserTo()
,ToNot()
et d’autres méthodes d’assertion pour faire des assertions avecgomega
allumettes.- Our CI runs
go test -v ./... -ginkgo.v
. Je dois créer une solution de contournement pour que la commande fonctionne avecxconvey
/non-ginkgo
forfait. Sans l’importation deginkgo
la commande avec échec (erreur : indicateurginkgo.v
n’est pas défini). Maisginkgo
ne peut pas être importé à la fois dans les packages de test et non-test (erreur : indicateur redéfini :ginkgo.seed
). Je dois donc l’importer dans le package non-test (xconvey
) pour rendre ses drapeaux toujours présents dans les tests et exiger que les paquets de test avecxconvey
ne doit PAS importerginkgo
. Le résultat est que nous ne pouvons pas mélangergomega
avecxconvey
tests dans le même paquet.
Au final, ça marche bien. On peut utiliser goconvey
écrire des tests avec gomega
. Le code est propre et facile à lire. Nous avons le meilleur des deux mondes : continuer à utiliser l’ensemble d’assertions familier tout en ayant la flexibilité de goconvey
.