Repository: losisin/helm-values-schema-json
Pull request #198 (2025-06-10 20:34:36)

chore: Added logger to improve test output
Adds logger interface that allows using `testing.T.Log` during tests, but uses `os.Stdout` in normal execution.

Now when 1 test fails, you won't get all the output from the other tests anymore, which makes it much more readable. This will help when debugging #191 

Added bonus is that the logs in the test results show which line caused the log:

```
=== RUN   TestHTTPLoader_Cache/sends_new_request_if_etag_save_fails
    loader.go:266: Loading http://127.0.0.1:35741
    loader.go:313: Error using etag cache: dummy error
    loader.go:366: Error saving response cache: dummy error
    loader.go:376: => got 2B in 0s
```

Closes #182



Patch:
diff --git a/pkg/bundle.go b/pkg/bundle.go
index 51188f9..5662c92 100644
--- a/pkg/bundle.go
+++ b/pkg/bundle.go
@@ -19,9 +19,7 @@ import (
 //
 // The paths, outputDir & bundleRoot, are only used to change absolute paths
 // into relative paths in a solely cosmetic way.
-func Bundle(schema *Schema, outputDir, bundleRoot string, withoutIDs bool) error {
-	ctx := context.Background()
-
+func Bundle(ctx context.Context, schema *Schema, outputDir, bundleRoot string, withoutIDs bool) error {
 	absOutputDir, err := filepath.Abs(filepath.Dir(outputDir))
 	if err != nil {
 		return fmt.Errorf("output %s: get absolute path: %w", outputDir, err)
diff --git a/pkg/cmd.go b/pkg/cmd.go
index e7b003d..88f75bb 100644
--- a/pkg/cmd.go
+++ b/pkg/cmd.go
@@ -41,7 +41,7 @@ func NewCmd() *cobra.Command {
 			if err != nil {
 				return err
 			}
-			return GenerateJsonSchema(config)
+			return GenerateJsonSchema(cmd.Context(), config)
 		},
 		SilenceErrors: true,
 		SilenceUsage:  true,
diff --git a/pkg/generator.go b/pkg/generator.go
index d0be8cc..eb85fae 100644
--- a/pkg/generator.go
+++ b/pkg/generator.go
@@ -1,6 +1,7 @@
 package pkg
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -12,7 +13,7 @@ import (
 )
 
 // Generate JSON schema
-func GenerateJsonSchema(config *Config) error {
+func GenerateJsonSchema(ctx context.Context, config *Config) error {
 	// Check if the values flag is set
 	if len(config.Values) == 0 {
 		return errors.New("values flag is required")
@@ -109,7 +110,7 @@ func GenerateJsonSchema(config *Config) error {
 	}
 
 	if config.Bundle {
-		if err := Bundle(mergedSchema, config.Output, config.BundleRoot, config.BundleWithoutID); err != nil {
+		if err := Bundle(ctx, mergedSchema, config.Output, config.BundleRoot, config.BundleWithoutID); err != nil {
 			return err
 		}
 	}
@@ -127,10 +128,12 @@ func GenerateJsonSchema(config *Config) error {
 		mergedSchema.AdditionalProperties = &SchemaFalse
 	}
 
-	return WriteOutput(mergedSchema, config.Output, indentString)
+	return WriteOutput(ctx, mergedSchema, config.Output, indentString)
 }
 
-func WriteOutput(mergedSchema *Schema, outputPath, indent string) error {
+func WriteOutput(ctx context.Context, mergedSchema *Schema, outputPath, indent string) error {
+	logger := LoggerFromContext(ctx)
+
 	// If validation is successful, marshal the schema and save to the file
 	jsonBytes, err := json.MarshalIndent(mergedSchema, "", indent)
 	if err != nil {
@@ -143,6 +146,6 @@ func WriteOutput(mergedSchema *Schema, outputPath, indent string) error {
 		return errors.New("error writing schema to file")
 	}
 
-	fmt.Println("JSON schema successfully generated")
+	logger.Log("JSON schema successfully generated")
 	return nil
 }
diff --git a/pkg/loader.go b/pkg/loader.go
index 96bc73c..580611d 100644
--- a/pkg/loader.go
+++ b/pkg/loader.go
@@ -123,7 +123,9 @@ func NewFileLoader(fs fs.FS, fsRootPath string) FileLoader {
 var _ Loader = FileLoader{}
 
 // Load implements [Loader].
-func (loader FileLoader) Load(_ context.Context, ref *url.URL) (*Schema, error) {
+func (loader FileLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
+	logger := LoggerFromContext(ctx)
+
 	if ref.Scheme != "file" && ref.Scheme != "" {
 		return nil, fmt.Errorf(`file url in $ref=%q must start with "file://", "./", or "/"`, ref)
 	}
@@ -141,7 +143,7 @@ func (loader FileLoader) Load(_ context.Context, ref *url.URL) (*Schema, error)
 		path = rel
 	}
 
-	fmt.Println("Loading file", path)
+	logger.Log("Loading file", path)
 	f, err := loader.fs.Open(path)
 	if err != nil {
 		return nil, err
@@ -152,7 +154,7 @@ func (loader FileLoader) Load(_ context.Context, ref *url.URL) (*Schema, error)
 		return nil, err
 	}
 
-	fmt.Printf("=> got %s\n", formatSizeBytes(len(b)))
+	logger.Logf("=> got %s", formatSizeBytes(len(b)))
 
 	var schema Schema
 	switch filepath.Ext(path) {
@@ -244,6 +246,7 @@ var failHTTPLoaderNewRequest bool
 
 // Load implements [Loader].
 func (loader HTTPLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error) {
+	logger := LoggerFromContext(ctx)
 	// Hardcoding a higher limit so CI/CD pipelines don't get stuck
 	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
 	defer cancel()
@@ -260,14 +263,14 @@ func (loader HTTPLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error
 		return nil, fmt.Errorf("create request: %w", err)
 	}
 
-	fmt.Println("Loading", req.URL.Redacted())
+	logger.Log("Loading", req.URL.Redacted())
 
 	cached, cachedSchema, err := loader.LoadCache(req)
 	if err != nil {
-		fmt.Println("Error loading from cache:", err)
+		logger.Log("Error loading from cache:", err)
 	} else if cachedSchema != nil {
 		duration := time.Since(start)
-		fmt.Printf("=> got %s from cache in %s (expires in %s)\n",
+		logger.Logf("=> got %s from cache in %s (expires in %s)",
 			formatSizeBytes(len(cached.Data)),
 			duration.Truncate(time.Millisecond),
 			time.Until(cached.Expiry()).Truncate(time.Second))
@@ -301,13 +304,13 @@ func (loader HTTPLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error
 		cached, schema, err := loader.SaveCacheETag(req, resp, cached)
 		if err == nil {
 			duration := time.Since(start)
-			fmt.Printf("=> renewed cache of %s in %s (expires in %s)\n",
+			logger.Logf("=> renewed cache of %s in %s (expires in %s)",
 				formatSizeBytes(len(cached.Data)),
 				duration.Truncate(time.Millisecond),
 				cached.MaxAge.Truncate(time.Second))
 			return schema, nil
 		}
-		fmt.Println("Error using etag cache:", err)
+		logger.Log("Error using etag cache:", err)
 		// Redo the request, but without the etag this time
 		req.Header.Del("If-None-Match")
 		newResp, err := loader.client.Do(req)
@@ -360,17 +363,17 @@ func (loader HTTPLoader) Load(ctx context.Context, ref *url.URL) (*Schema, error
 
 	cached, err = loader.SaveCache(req, resp, b)
 	if err != nil {
-		fmt.Println("Error saving response cache:", err)
+		logger.Log("Error saving response cache:", err)
 	}
 
 	duration := time.Since(start)
 	if cached.MaxAge > 0 {
-		fmt.Printf("=> cached %s in %s (expires in %s)\n",
+		logger.Logf("=> cached %s in %s (expires in %s)",
 			formatSizeBytes(len(b)),
 			duration.Truncate(time.Millisecond),
 			cached.MaxAge.Truncate(time.Second))
 	} else {
-		fmt.Printf("=> got %s in %s\n",
+		logger.Logf("=> got %s in %s",
 			formatSizeBytes(len(b)),
 			duration.Truncate(time.Millisecond))
 	}
diff --git a/pkg/log.go b/pkg/log.go
new file mode 100644
index 0000000..b4ab6d5
--- /dev/null
+++ b/pkg/log.go
@@ -0,0 +1,61 @@
+package pkg
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os"
+)
+
+type loggerContextKey int
+
+var loggerContextValue = loggerContextKey(1)
+
+// Logger is an interface that allows writing log messages to the user.
+//
+// The interface is intentionally shaped to also allow [testing.T] as a logger.
+type Logger interface {
+	// Prints message as a line.
+	// No need to suffix with a newline.
+	Log(a ...any)
+	// Prints formatted message as a line.
+	// No need to suffix with a newline.
+	Logf(format string, a ...any)
+}
+
+// ContextWithLogger returns a derived context that sets the current logger.
+// This logger is later retrieved using [LoggerFromContext].
+func ContextWithLogger(parent context.Context, logger Logger) context.Context {
+	return context.WithValue(parent, loggerContextValue, logger)
+}
+
+// LoggerFromContext tries to find the current logger from the context,
+// and if none set then will return a new logger that uses [os.Stdout].
+func LoggerFromContext(ctx context.Context) Logger {
+	logger := ctx.Value(loggerContextValue)
+	if logger == nil {
+		return NewLogger(os.Stdout)
+	}
+	return logger.(Logger)
+}
+
+// NewLogger returns a new logger using the provided writer.
+func NewLogger(output io.Writer) WriterLogger {
+	return WriterLogger{output}
+}
+
+// WriterLogger is a [Logger] implementation that writes to a [io.Writer].
+type WriterLogger struct {
+	Output io.Writer
+}
+
+// ensures it implements the interface
+var _ Logger = WriterLogger{}
+
+func (logger WriterLogger) Log(a ...any) {
+	_, _ = fmt.Fprintln(logger.Output, a...)
+}
+
+func (logger WriterLogger) Logf(format string, a ...any) {
+	_, _ = fmt.Fprintf(logger.Output, format+"\n", a...)
+}


Test patch:
diff --git a/pkg/bundle_test.go b/pkg/bundle_test.go
index 3aac8eb..0c61aad 100644
--- a/pkg/bundle_test.go
+++ b/pkg/bundle_test.go
@@ -26,7 +26,8 @@ func TestBundleWithLoader_RemoveIDsError(t *testing.T) {
 		withoutIDs   = true
 	)
 
-	err := bundleWithLoader(t.Context(), loader, schema, absOutputDir, withoutIDs)
+	ctx := ContextWithLogger(t.Context(), t)
+	err := bundleWithLoader(ctx, loader, schema, absOutputDir, withoutIDs)
 	assert.ErrorContains(t, err, "remove bundled $id: /$ref: no $defs found that matches $ref=\"foo.json\"")
 }
 
@@ -414,7 +415,8 @@ func TestBundle(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
-			err := BundleSchema(t.Context(), tt.loader, tt.schema, "/")
+			ctx := ContextWithLogger(t.Context(), t)
+			err := BundleSchema(ctx, tt.loader, tt.schema, "/")
 			require.NoError(t, err)
 			assert.Equal(t, tt.want, tt.schema)
 
@@ -466,7 +468,8 @@ func TestBundle_Errors(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
-			err := BundleSchema(t.Context(), tt.loader, tt.schema, "/")
+			ctx := ContextWithLogger(t.Context(), t)
+			err := BundleSchema(ctx, tt.loader, tt.schema, "/")
 			assert.EqualError(t, err, tt.wantErr)
 		})
 	}
diff --git a/pkg/generator_test.go b/pkg/generator_test.go
index c9ecce3..cdd85e1 100644
--- a/pkg/generator_test.go
+++ b/pkg/generator_test.go
@@ -323,7 +323,8 @@ func TestGenerateJsonSchema(t *testing.T) {
 			require.NoError(t, err)
 			tt.config.SchemaRoot.RefReferrer = ReferrerDir(cwd)
 
-			err = GenerateJsonSchema(tt.config)
+			ctx := ContextWithLogger(t.Context(), t)
+			err = GenerateJsonSchema(ctx, tt.config)
 			require.NoError(t, err, "Error from pkg.GenerateJsonSchema")
 
 			generatedBytes, err := os.ReadFile(tt.config.Output)
@@ -515,7 +516,8 @@ func TestGenerateJsonSchema_Errors(t *testing.T) {
 				assert.NoError(t, err)
 			}
 
-			err := GenerateJsonSchema(tt.config)
+			ctx := ContextWithLogger(t.Context(), t)
+			err := GenerateJsonSchema(ctx, tt.config)
 			assert.Error(t, err)
 			if err != nil {
 				assert.Contains(t, err.Error(), tt.expectedErr.Error())
@@ -532,7 +534,8 @@ func TestGenerateJsonSchema_Errors(t *testing.T) {
 func TestGenerateJsonSchema_AbsInputError(t *testing.T) {
 	testutil.MakeGetwdFail(t)
 
-	err := GenerateJsonSchema(&Config{
+	ctx := ContextWithLogger(t.Context(), t)
+	err := GenerateJsonSchema(ctx, &Config{
 		Values: []string{"foo/bar.yaml"},
 		Draft:  2020,
 		Indent: 4,
@@ -544,7 +547,8 @@ func TestGenerateJsonSchema_AbsOutputError(t *testing.T) {
 	input := testutil.WriteTempFile(t, "values-*.yaml", []byte(""))
 	testutil.MakeGetwdFail(t)
 
-	err := GenerateJsonSchema(&Config{
+	ctx := ContextWithLogger(t.Context(), t)
+	err := GenerateJsonSchema(ctx, &Config{
 		Values: []string{input.Name()}, // unaffected by failing [os.Getwd] because it is an absolute path
 		Output: "foo.json",
 		Draft:  2020,
@@ -558,7 +562,8 @@ func TestGenerateJsonSchema_AbsBundleRootError(t *testing.T) {
 	input := testutil.WriteTempFile(t, "values-*.yaml", []byte(""))
 	testutil.MakeGetwdFail(t)
 
-	err := GenerateJsonSchema(&Config{
+	ctx := ContextWithLogger(t.Context(), t)
+	err := GenerateJsonSchema(ctx, &Config{
 		Values:     []string{input.Name()}, // unaffected by failing [os.Getwd] because it is an absolute path
 		Output:     "/tmp/foo.json",        // unaffected by failing [os.Getwd] because it is an absolute path
 		Draft:      2020,
@@ -621,7 +626,8 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) {
 				},
 			}
 
-			err := GenerateJsonSchema(config)
+			ctx := ContextWithLogger(t.Context(), t)
+			err := GenerateJsonSchema(ctx, config)
 			assert.NoError(t, err)
 
 			generatedBytes, err := os.ReadFile(config.Output)
@@ -648,6 +654,7 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) {
 func TestWriteOutput_JSONError(t *testing.T) {
 	schema := &Schema{Type: func() {}}
 
-	err := WriteOutput(schema, os.DevNull, "  ")
+	ctx := ContextWithLogger(t.Context(), t)
+	err := WriteOutput(ctx, schema, os.DevNull, "  ")
 	require.ErrorContains(t, err, "unsupported type: func()")
 }
diff --git a/pkg/loader_test.go b/pkg/loader_test.go
index 6221cba..a5db51a 100644
--- a/pkg/loader_test.go
+++ b/pkg/loader_test.go
@@ -48,7 +48,8 @@ func TestLoad_Errors(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
-			_, err := Load(t.Context(), tt.loader, tt.ref, "/")
+			ctx := ContextWithLogger(t.Context(), t)
+			_, err := Load(ctx, tt.loader, tt.ref, "/")
 			assert.EqualError(t, err, tt.wantErr)
 		})
 	}
@@ -108,7 +109,8 @@ func TestFileLoader_Error(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			loader := NewFileLoader((*RootFS)(root), tt.fsRootPath)
-			_, err := loader.Load(t.Context(), tt.url)
+			ctx := ContextWithLogger(t.Context(), t)
+			_, err := loader.Load(ctx, tt.url)
 			assert.ErrorContains(t, err, tt.wantErr)
 		})
 	}
@@ -161,7 +163,8 @@ func TestFileLoader_TestFSError(t *testing.T) {
 	}
 
 	loader := NewFileLoader(root, "")
-	_, err := loader.Load(t.Context(), mustParseURL("./some-fake-file.txt"))
+	ctx := ContextWithLogger(t.Context(), t)
+	_, err := loader.Load(ctx, mustParseURL("./some-fake-file.txt"))
 	assert.ErrorContains(t, err, "dummy error")
 }
 
@@ -196,7 +199,8 @@ func TestURLSchemeLoader_Error(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			_, err := tt.loader.Load(t.Context(), tt.url)
+			ctx := ContextWithLogger(t.Context(), t)
+			_, err := tt.loader.Load(ctx, tt.url)
 			assert.ErrorContains(t, err, tt.wantErr)
 		})
 	}
@@ -213,11 +217,12 @@ func TestCacheLoader(t *testing.T) {
 		},
 	})
 
-	schema1, err := loader.Load(t.Context(), mustParseURL("foo://"))
+	ctx := ContextWithLogger(t.Context(), t)
+	schema1, err := loader.Load(ctx, mustParseURL("foo://"))
 	require.NoError(t, err)
-	schema2, err := loader.Load(t.Context(), mustParseURL("foo://"))
+	schema2, err := loader.Load(ctx, mustParseURL("foo://"))
 	require.NoError(t, err)
-	schema3, err := loader.Load(t.Context(), mustParseURL("foo://"))
+	schema3, err := loader.Load(ctx, mustParseURL("foo://"))
 	require.NoError(t, err)
 
 	assert.Same(t, schema1, schema2)
@@ -352,7 +357,7 @@ func TestHTTPLoader(t *testing.T) {
 				}))
 				defer server.Close()
 
-				ctx := t.Context()
+				ctx := ContextWithLogger(t.Context(), t)
 
 				loader := NewHTTPLoader(server.Client(), nil)
 				loader.UserAgent = "test/" + t.Name()
@@ -383,7 +388,7 @@ func TestHTTPLoader(t *testing.T) {
 			}))
 			defer server.Close()
 
-			ctx := t.Context()
+			ctx := ContextWithLogger(t.Context(), t)
 			ctx = ContextWithLoaderReferrer(ctx, "http://some-referrerer")
 
 			loader := NewHTTPLoader(server.Client(), nil)
@@ -420,7 +425,8 @@ func TestHTTPLoader_Cache(t *testing.T) {
 			w.Header().Set("Cache-Control", "no-cache")
 			return []byte("{}")
 		})
-		_, err := loader.Load(t.Context(), mustParseURL(server.URL))
+		ctx := ContextWithLogger(t.Context(), t)
+		_, err := loader.Load(ctx, mustParseURL(server.URL))
 		require.NoError(t, err)
 
 		assert.Empty(t, cache.Map)
@@ -431,7 +437,8 @@ func TestHTTPLoader_Cache(t *testing.T) {
 			w.Header().Set("Cache-Control", "max-age=100")
 			return []byte("{}")
 		})
-		_, err := loader.Load(t.Context(), mustParseURL(server.URL))
+		ctx := ContextWithLogger(t.Context(), t)
+		_, err := loader.Load(ctx, mustParseURL(server.URL))
 		require.NoError(t, err)
 
 		require.Len(t, cache.Map, 1)
@@ -456,7 +463,8 @@ func TestHTTPLoader_Cache(t *testing.T) {
 		}
 		cache.Map[server.URL] = alreadyCached
 
-		schema, err := loader.Load(t.Context(), mustParseURL(server.URL))
+		ctx := ContextWithLogger(t.Context(), t)
+		schema, err := loader.Load(ctx, mustParseURL(server.URL))
 		require.NoError(t, err)
 
 		assert.Equal(t, "Already cached", schema.Comment)
@@ -477,7 +485,8 @@ func TestHTTPLoader_Cache(t *testing.T) {
 		require.False(t, alreadyCached.Expired()) // sanity check
 		cache.Map[server.URL] = alreadyCached
 
-		schema, err := loader.Load(t.Context(), mustParseURL(server.URL))
+		ctx := ContextWithLogger(t.Context(), t)
+		schema, err := loader.Load(ctx, mustParseURL(server.URL))
 		require.NoError(t, err)
 		assert.Equal(t, "from server", schema.Comment)
 	})
@@ -495,7 +504,8 @@ func TestHTTPLoader_Cache(t *testing.T) {
 			},
 		}
 
-		_, err := loader.Load(t.Context(), mustParseURL(server.URL))
+		ctx := ContextWithLogger(t.Context(), t)
+		_, err := loader.Load(ctx, mustParseURL(server.URL))
 
 		// No error. Should still just work even though the cache isn't.
 		require.NoError(t, err)
@@ -517,7 +527,8 @@ func TestHTTPLoader_Cache(t *testing.T) {
 		cache.Map[server.URL] = alreadyCached
 		require.True(t, alreadyCached.Expired()) // sanity check
 
-		schema, err := loader.Load(t.Context(), mustParseURL(server.URL))
+		ctx := ContextWithLogger(t.Context(), t)
+		schema, err := loader.Load(ctx, mustParseURL(server.URL))
 		require.NoError(t, err)
 
 		assert.Equal(t, []string{"myETag"}, etagsReceived)
@@ -551,7 +562,8 @@ func TestHTTPLoader_Cache(t *testing.T) {
 			},
 		}
 
-		schema, err := loader.Load(t.Context(), mustParseURL(server.URL))
+		ctx := ContextWithLogger(t.Context(), t)
+		schema, err := loader.Load(ctx, mustParseURL(server.URL))
 		require.NoError(t, err)
 
 		assert.Equal(t, 2, requestsReceived)
@@ -587,7 +599,8 @@ func TestHTTPLoader_Cache(t *testing.T) {
 			},
 		}
 
-		_, err := loader.Load(t.Context(), mustParseURL(server.URL))
+		ctx := ContextWithLogger(t.Context(), t)
+		_, err := loader.Load(ctx, mustParseURL(server.URL))
 		require.ErrorContains(t, err, "request $ref over HTTP:")
 		assert.Equal(t, 1, requestsReceived)
 	})
@@ -692,7 +705,7 @@ func TestHTTPLoader_Error(t *testing.T) {
 			}))
 			defer server.Close()
 
-			ctx := t.Context()
+			ctx := ContextWithLogger(t.Context(), t)
 
 			loader := NewHTTPLoader(server.Client(), nil)
 			_, err := loader.Load(ctx, mustParseURL(server.URL))
@@ -710,7 +723,7 @@ func TestHTTPLoader_Error(t *testing.T) {
 		}))
 		defer server.Close()
 
-		ctx := t.Context()
+		ctx := ContextWithLogger(t.Context(), t)
 
 		loader := NewHTTPLoader(server.Client(), nil)
 		_, err := loader.Load(ctx, mustParseURL(server.URL))
@@ -726,7 +739,7 @@ func TestHTTPLoader_Error(t *testing.T) {
 		}))
 		defer server.Close()
 
-		ctx := t.Context()
+		ctx := ContextWithLogger(t.Context(), t)
 
 		loader := NewHTTPLoader(server.Client(), nil)
 		loader.SizeLimit = 20
@@ -744,7 +757,7 @@ func TestHTTPLoader_Error(t *testing.T) {
 		}))
 		server.Close() // close now already
 
-		ctx := t.Context()
+		ctx := ContextWithLogger(t.Context(), t)
 
 		loader := NewHTTPLoader(server.Client(), nil)
 		_, err := loader.Load(ctx, mustParseURL(server.URL))
@@ -761,7 +774,7 @@ func TestHTTPLoader_Error(t *testing.T) {
 		}))
 		defer server.Close()
 
-		ctx := t.Context()
+		ctx := ContextWithLogger(t.Context(), t)
 
 		loader := NewHTTPLoader(server.Client(), nil)
 		_, err := loader.Load(ctx, mustParseURL(server.URL))
@@ -773,7 +786,8 @@ func TestHTTPLoader_NewRequestError(t *testing.T) {
 	failHTTPLoaderNewRequest = true
 	defer func() { failHTTPLoaderNewRequest = false }()
 	loader := NewHTTPLoader(http.DefaultClient, nil)
-	_, err := loader.Load(t.Context(), mustParseURL("file://localhost"))
+	ctx := ContextWithLogger(t.Context(), t)
+	_, err := loader.Load(ctx, mustParseURL("file://localhost"))
 	assert.ErrorContains(t, err, `create request: `)
 }
 
diff --git a/pkg/log_test.go b/pkg/log_test.go
new file mode 100644
index 0000000..095bbf2
--- /dev/null
+++ b/pkg/log_test.go
@@ -0,0 +1,40 @@
+package pkg
+
+import (
+	"bytes"
+	"context"
+	"os"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWriterLogger(t *testing.T) {
+	t.Run("log", func(t *testing.T) {
+		var buf bytes.Buffer
+		logger := NewLogger(&buf)
+		logger.Log("hello", "there")
+		assert.Equal(t, "hello there\n", buf.String())
+	})
+
+	t.Run("logf", func(t *testing.T) {
+		var buf bytes.Buffer
+		logger := NewLogger(&buf)
+		logger.Logf("hello %q", "there")
+		assert.Equal(t, "hello \"there\"\n", buf.String())
+	})
+
+	t.Run("default logger from empty context", func(t *testing.T) {
+		logger := LoggerFromContext(context.Background())
+		require.IsType(t, WriterLogger{}, logger)
+		assert.Same(t, os.Stdout, logger.(WriterLogger).Output)
+	})
+
+	t.Run("logger from context", func(t *testing.T) {
+		ctx := ContextWithLogger(context.Background(), NewLogger(nil))
+		logger := LoggerFromContext(ctx)
+		require.IsType(t, WriterLogger{}, logger)
+		assert.Nil(t, logger.(WriterLogger).Output)
+	})
+}