Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add redisxtest #484

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open

Add redisxtest #484

wants to merge 14 commits into from

Conversation

zack-keim
Copy link

@zack-keim zack-keim commented Feb 28, 2022

Summary

Adds redisxtest implementation which provides setup and teardown functions for setting up a mock redis cluster and client for testing purposes. One of the challenges of using this package is that testing clients requires writing setup and teardown functions for the mock redis instance. This leads to a lot of copy and paste across baseplate.go services, since the pattern is nearly identical. This change makes it possible to set up a mock redis cluster and client of that cluster with two simple functions.

What to look out for

Current implementation uses miniredis to mock the redis instance. miniredis is the currently the predominately used library for standing up a local in-memory redis instance for testing purposes in go. However, there may be other options out there.

@zack-keim zack-keim changed the title Add Redistest Add redisxtest Feb 28, 2022
@zack-keim zack-keim marked this pull request as ready for review March 1, 2022 00:50
@zack-keim zack-keim requested a review from a team as a code owner March 1, 2022 00:50
@zack-keim zack-keim requested review from fishy, kylelemons and konradreiche and removed request for a team March 1, 2022 00:50
Copy link
Member

@konradreiche konradreiche left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we apply this to existing test code in this repository while we are at it? I noticed that we use miniredis here too:

func NewMockRedisClient(ctx context.Context, timeout time.Duration) (client redisx.Syncx, teardown func(), err error) {
redisCluster, err := miniredis.Run()
if err != nil {
return redisx.Syncx{}, func() {}, err
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: in an error return, we would want to return nil for non-error returns whenever possible. you cannot return nil for the client, but you certainly can return nil for the teardown (we don't want people using it to blindly defer teardown() without checking for errors first).


// NewMockRedisClient sets up a mock redis cluster, client and sender
// This should be called from TestMain since the miniredis instance created is locked
func NewMockRedisClient(ctx context.Context, timeout time.Duration) (client redisx.Syncx, teardown func(), err error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this is only intended to be used by unit tests, we do not really need to return teardown and err. just pass intb testing.TB as an arg (so it can be used by both tests and benchmarks), then when an error happens we can just tb.Fatalf("Failed to ...: %v", err), and at the end, before returning, we can just call tb.Cleanup(teardown).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the main use case is to set this up in testing.M which sadly does not have those those methods available.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@konradreiche that is correct. We have been following the pattern of setup in TestMain since places where the mock redis cluster is used tend to be places where it made sense to init there. This approach also gives the flexibility to use this func in both tests directly but also in TestMain. However, if we feel strongly that supporting TestMain doesn't have much value, then I am happy to change this.

return client, teardown, nil
}

func FlushRedis(ctx context.Context, client redisx.Syncx) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see this used by NewMockRedisClient, and it's not explained by the PR description either.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing. This was a func we were using locally that got dropped in here. Agree it is probably not necessary.

@zack-keim zack-keim requested review from konradreiche and fishy March 2, 2022 22:21
@@ -53,7 +60,9 @@ func TestMain(m *testing.M) {
client = redisx.BaseSync{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this value of client is no longer used by anything?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

// NewMockRedisClient sets up a client and sender to a mock redis cluster
func NewMockRedisClient(
ctx context.Context,
redisCluster MockRedisCluster,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this feels like an over-design to me.

if in most tests we only need a single, global, mocked cluster and client, then we don't really need separated NewMockRedisCluster and NewMockRedisClient, a single NewMockRedisClient with its returned teardown also tears down the cluster should be sufficient (the cluster does not need to be exposed to the caller at all).

but if that's not the case, then I think only the cluster itself should be global (that's handled inside TestMain), the clients should be created per test, so my previous comment regarding passing in testing.TB and no need to return error nor teardown applies.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and looking at this function again I don't see it doing anything special. all it does are pretty standard stuff about create a redis connection (you actually only need the address passed in, not the whole cluster object) and create a redis client out of this connection. the teardown is also standard stuff about closing the client, so I don't really see much value of providing (and maintaining) this as part of the public API.

If we think a func NewClient(ctx, addr, opts) (redisx.BaseSync, error) API is useful, then that should be part of redisx, not redisxtest.

Copy link
Author

@zack-keim zack-keim Mar 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reason for the change to have separated NewMockRedisCluster and NewMockRedisClient was to support the cases like that in TestMain in init_test, where it looks like we need a global cluster. Perhaps an alternative solution to this would be to have the following API for redisxtest:

func NewMockRedisClient(tb, ctx, connOpts) (client, error)
func NewMockRedisClientWithCluster(tb, ctx, clusterAddr, connOpts) (client, error)

These functions would handle cleanup and client creation via tb as you have suggested.

I am really not opposed at all to going down the path updating the redisx API to have a func NewClient(). This is actually our preferred route. We saw adding redisxtest as being minimally invasive, which was the reason for going this path. If you are ok with us doing that, happy to open another PR for that change and close potentially close this one if we see no value in what I have proposed.

// Create connection
conn, err := redisconn.Connect(ctx, redisCluster.Addr(), opts)
if err != nil {
redisCluster.Close()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a very unexpected side effect. if a client cannot connect to the server, it will also kill the server, causing other, clients to stop working.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

}

// Close shuts down the MockRedisCluster
func (mrc *MockRedisCluster) Close() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: make this function to return an error, so it implements io.Closer interface

since miniredis.Miniredis.Close does not return an error, you can just always return nil inside this function.

Comment on lines 28 to 30
teardown = func() {
mockRedisCluster.Close()
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a very standard closing pattern in go (especially when you make MockRedisCluster to implement io.Closer), so I would say returning a teardown is unnecessary, caller could just do:

cluster, err := NewMockRedisCluster()
if err != nil { ... }
defer cluster.Close()

// NewMockRedisClient sets up a client and sender to a mock redis cluster
func NewMockRedisClient(
ctx context.Context,
redisCluster MockRedisCluster,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and looking at this function again I don't see it doing anything special. all it does are pretty standard stuff about create a redis connection (you actually only need the address passed in, not the whole cluster object) and create a redis client out of this connection. the teardown is also standard stuff about closing the client, so I don't really see much value of providing (and maintaining) this as part of the public API.

If we think a func NewClient(ctx, addr, opts) (redisx.BaseSync, error) API is useful, then that should be part of redisx, not redisxtest.

@zack-keim zack-keim requested a review from fishy March 3, 2022 01:06
Comment on lines 41 to 45
func NewMockRedisClient(
ctx context.Context,
address string,
opts redisconn.Opts,
) (client redisx.BaseSync, teardown func(), err error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still recommend change this function signature into:

func NewMockRedisClient(ctx context.Context, tb testing.TB, address string, opts redisconn.Opts) redisx.BaseSync

it's OK for the mocked cluster to be a global singleton that's initialized in TestMain and shared between tests, because a mocked cluster is probably using much more resource and stand it up and tear it down in every test case is probably too slow/wasteful, but for clients, the pattern we should promote should be that every unit test create it and clean it up, instead of doing it in TestMain.

@zack-keim zack-keim requested a review from fishy March 9, 2022 20:18
Copy link
Member

@fishy fishy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we would still want to change the existing tests to use this, that includes:

  1. instead of store the client globally ( ), we store the miniredis address globally
  2. in tests currently using the global client, use this function to get the client instead
  3. change flushRedis to take tb and client as args.

Comment on lines +33 to +36
// Cleanup
tb.Cleanup(func() {
conn.Close()
})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would move this up to right after the err check (to line 27).

Comment on lines +13 to +14
// NewMockRedisClient sets up a redis client for use in testing
func NewMockRedisClient(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now there's nothing really "mocking" here (the mocking part is done via miniredis, which is no longer done here).

I would rename this function to NewTestRedisClient, and document that:

  1. the address arg should be coming from miniredis (we should also provide an example on how to it, see https://pkg.go.dev/testing#hdr-Examples, we also have prior arts in baseplate.go, for example https://github.com/reddit/baseplate.go/blob/master/errorsbp/batch_size_example_test.go)
  2. we should also document that the returned client will be auto released at the end of test and there's no need to do that explicitly (because the caller actually still have access to that via client.SyncCtx.S.Close()

@@ -0,0 +1,4 @@
// Package redisxtest provides utilities for baseplate.go/redids/cache/redisx testing.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to spell out the full baseplate.go/redids/cache/redisx, just say "... for redisx testing" is enough.

@@ -0,0 +1,4 @@
// Package redisxtest provides utilities for baseplate.go/redids/cache/redisx testing.
//
// This includes functions for mocking redis instances.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment here regarding "mocking". I think with the current state of this change the first paragraph is enough for the package doc and we can just remove this paragraph.

tb testing.TB,
address string,
opts redisconn.Opts,
) (*redisx.BaseSync, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at how existing tests work, we do need the returned client to be of type redisx.BaseSync instead of the pointer, for example

wClient := redispipebp.WrapErrorsSync{Sync: client}

Comment on lines +24 to +26
if err != nil {
return nil, err
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh and this function no longer need to return an error. this can be just:

Suggested change
if err != nil {
return nil, err
}
if err != nil {
tb.Fatalf("Failed to create redis client: %v", err)
}

// Create connection to redis
conn, err := redisconn.Connect(ctx, address, opts)
if err != nil {
return nil, err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a testing helper, in my opinion it's better to call t.Fatalf and document that it must be called directly in a test / subtest, rather than do what is inevitable: have every user of this make their own local helper that wraps this helper that does the transition. I prefix my t.Fatalf errors with "SETUP:" to make it clear that it's not a failure of the code under test, and I would name the function "Setup" not "New" to encourage that interpretation as well.


// Cleanup
tb.Cleanup(func() {
conn.Close()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check the error and log it if it fails?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

4 participants