From 3f5347011b121c53e769dc530fb9142ec057abfb Mon Sep 17 00:00:00 2001 From: tx3stn <14163530+tx3stn@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:51:51 +0100 Subject: [PATCH] fix: seeded mock generation now gives the same output on every run --- renderer/mock_generator.go | 2 ++ renderer/mock_generator_test.go | 40 +++++++++++++++++++++++++++++ renderer/mock_generator_xml.go | 4 +-- renderer/mock_generator_xml_test.go | 35 +++++++++++++++++++++++++ renderer/mock_value_renderer.go | 6 ++--- renderer/schema_renderer.go | 18 +++++++++++-- 6 files changed, 98 insertions(+), 7 deletions(-) diff --git a/renderer/mock_generator.go b/renderer/mock_generator.go index 104294b3..3d52c048 100644 --- a/renderer/mock_generator.go +++ b/renderer/mock_generator.go @@ -98,6 +98,8 @@ func (mg *MockGenerator) SetMockGenerationOptions(options MockGenerationOptions) // SetSeed sets a specific seed for the random number generator used by this mock generator. // This is useful for generating deterministic mocks for testing purposes. +// +// Once a seed is set, date and time formats render from a fixed instant instead of the clock. func (mg *MockGenerator) SetSeed(seed int64) { mg.renderer.SetSeed(seed) } diff --git a/renderer/mock_generator_test.go b/renderer/mock_generator_test.go index b9ff0b5c..5278161e 100644 --- a/renderer/mock_generator_test.go +++ b/renderer/mock_generator_test.go @@ -575,6 +575,46 @@ properties: assert.NotEqual(t, generate(42), generate(123)) } +func TestMockGenerator_SetSeed_DeterministicDatesAndTimes(t *testing.T) { + dateSchema := `type: object +properties: + createdAt: + type: string + format: date-time + birthday: + type: string + format: date + opensAt: + type: string + format: time` + + mg := NewMockGenerator(JSON) + mg.SetSeed(42) + mock, err := mg.GenerateMock(createFakeMock(dateSchema, nil, nil), "") + assert.NoError(t, err) + + assert.JSONEq(t, `{"createdAt":"2025-01-01T12:00:00Z","birthday":"2025-01-01","opensAt":"12:00:00"}`, string(mock)) +} + +func TestMockGenerator_SetSeed_DeterministicUUID(t *testing.T) { + uuidSchema := `type: object +properties: + id: + type: string + format: uuid` + + generate := func(seed int64) string { + mg := NewMockGenerator(JSON) + mg.SetSeed(seed) + mock, err := mg.GenerateMock(createFakeMock(uuidSchema, nil, nil), "") + assert.NoError(t, err) + return string(mock) + } + + assert.Equal(t, generate(42), generate(42)) + assert.NotEqual(t, generate(42), generate(123)) +} + func TestMockGenerator_GenerateMock_PickNamedExample(t *testing.T) { mg := NewMockGenerator(YAML) diff --git a/renderer/mock_generator_xml.go b/renderer/mock_generator_xml.go index 0ddaa918..a25a7562 100644 --- a/renderer/mock_generator_xml.go +++ b/renderer/mock_generator_xml.go @@ -255,8 +255,8 @@ func (mg *MockGenerator) renderXMLMap(enc *xml.Encoder, start xml.StartElement, case "none": // Skip the node itself, include sub-properties directly if subMap, ok := val.(map[string]any); ok { - for sk, sv := range subMap { - children = append(children, childEntry{key: sk, value: sv, schema: getPropertySchema(propSchema, sk)}) + for _, sk := range orderedMapKeys(subMap, propSchema) { + children = append(children, childEntry{key: sk, value: subMap[sk], schema: getPropertySchema(propSchema, sk)}) } } else { children = append(children, childEntry{key: key, value: val, schema: propSchema}) diff --git a/renderer/mock_generator_xml_test.go b/renderer/mock_generator_xml_test.go index 90a0b6a1..539b66ab 100644 --- a/renderer/mock_generator_xml_test.go +++ b/renderer/mock_generator_xml_test.go @@ -569,6 +569,41 @@ properties: assertValidXML(t, result) } +func TestRenderXML_NodeTypeNoneChildrenFollowTheSchema(t *testing.T) { + schema := createSchemaFromYAML(t, ` +type: object +xml: + name: person +properties: + profile: + type: object + xml: + nodeType: none + properties: + zebra: + type: string + apple: + type: string + mango: + type: string +`) + + value := map[string]any{ + "profile": map[string]any{"apple": "a", "mango": "m", "zebra": "z"}, + } + + mg := NewMockGenerator(XML) + first := string(mg.RenderXML(value, schema)) + + assert.Less(t, strings.Index(first, ""), strings.Index(first, ""), + "flattened children should follow the order the schema declares, not the map") + assert.Less(t, strings.Index(first, ""), strings.Index(first, "")) + + for range 20 { + assert.Equal(t, first, string(mg.RenderXML(value, schema))) + } +} + func TestRenderXML_NodeTypeNoneScalarFallsBackToElement(t *testing.T) { mg := NewMockGenerator(XML) diff --git a/renderer/mock_value_renderer.go b/renderer/mock_value_renderer.go index 1c3702a6..50b232ad 100644 --- a/renderer/mock_value_renderer.go +++ b/renderer/mock_value_renderer.go @@ -64,11 +64,11 @@ func (wr *SchemaRenderer) renderMockStringValue(schema *base.Schema, key string, switch schema.Format { case dateTimeType: - return limitGeneratedString(time.Now().Format(time.RFC3339)) + return limitGeneratedString(wr.now().Format(time.RFC3339)) case dateType: - return limitGeneratedString(time.Now().Format("2006-01-02")) + return limitGeneratedString(wr.now().Format("2006-01-02")) case timeType: - return limitGeneratedString(time.Now().Format("15:04:05")) + return limitGeneratedString(wr.now().Format("15:04:05")) case emailType: return limitGeneratedString(fmt.Sprintf("%s@%s.com", randomWord(), randomWord())) case hostnameType: diff --git a/renderer/schema_renderer.go b/renderer/schema_renderer.go index b5ed3bb9..479ec5d0 100644 --- a/renderer/schema_renderer.go +++ b/renderer/schema_renderer.go @@ -4,7 +4,6 @@ package renderer import ( - cryptoRand "crypto/rand" "errors" "fmt" "io" @@ -91,10 +90,15 @@ type SchemaRenderer struct { words []string disableRequired bool rand *rand.Rand + seeded bool onUnresolvedRef UnresolvedRefHandler mockOptions MockGenerationOptions } +// seededTime is the instant a seeded renderer renders date and time formats from, so they +// stay the same between runs. It is in UTC so the output does not depend on the host's zone. +var seededTime = time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC) + // MockGenerationBudgetError describes which mock generation budget was exceeded. type MockGenerationBudgetError struct { // Budget is the name of the budget that was exceeded. @@ -222,8 +226,18 @@ func (wr *SchemaRenderer) DisableRequiredCheck() { // SetSeed sets a specific seed for the random number generator used by this renderer. // This is useful for generating deterministic mocks for testing purposes. +// +// Once a seed is set, date and time formats render from a fixed instant instead of the clock. func (wr *SchemaRenderer) SetSeed(seed int64) { wr.rand = rand.New(rand.NewSource(seed)) + wr.seeded = true +} + +func (wr *SchemaRenderer) now() time.Time { + if wr.seeded { + return seededTime + } + return time.Now() } // DiveIntoSchema renders a schema into structure at key. @@ -672,6 +686,6 @@ func (wr *SchemaRenderer) RandomFloat64() float64 { // PseudoUUID returns a UUID-shaped random value for mock data. func (wr *SchemaRenderer) PseudoUUID() string { b := make([]byte, 16) - _, _ = cryptoRand.Read(b) + _, _ = wr.rand.Read(b) return strings.ToLower(fmt.Sprintf("%X-%X-%X-%X-%X", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])) }