使用接口来模拟amqp091-go的困难

huangapple go评论86阅读模式
英文:

Mocking amqp091-go difficulties with interfaces

问题

我正在尝试为一个简化和稍作修改的Go AMQP消费者编写单元测试,用于从RabbitMQ队列中消费消息并将其转发到AWS SQS队列的简单实用程序。我在模拟Connection和Channel结构时遇到了困难 - 对Go还比较陌生 - 有关如何解决此问题的任何想法吗?我在playground中写了一个代码片段 - 删除了大部分内容,只保留了关键部分。问题是这样的:

https://go.dev/play/p/ybSB6EU3siO

go: finding module for package github.com/rabbitmq/amqp091-go
go: downloading github.com/rabbitmq/amqp091-go v1.8.1
go: found github.com/rabbitmq/amqp091-go in github.com/rabbitmq/amqp091-go v1.8.1
# play
./prog.go:23:16: cannot use dial(url) (value of type *amqp091.Connection) as Connection value in assignment: *amqp091.Connection does not implement Connection (wrong type for method Channel)
		have Channel() (*amqp091.Channel, error)
		want Channel() (Channel, error)

Go build failed.

我试图模拟的实际函数是:

https://github.com/rabbitmq/amqp091-go/blob/579207b03cecc66c1b206679b79f267c8c734db7/connection.go#L843

以下是用于说明问题的示例代码:

package main

import amqp "github.com/rabbitmq/amqp091-go"

type Channel interface {
	Cancel(consumer string, noWait bool) error
}

type Connection interface {
	Channel() (Channel, error)
	Close() error
}

type Consumer struct {
	conn    Connection
	channel Channel
	tag     string
	done    chan error
}

func (c *Consumer) Consume(url string) error {
	var err error
	c.conn, err = dial(url)
	return err
}

var dial = func(url string) (*amqp.Connection, error) {
	return &amqp.Connection{}, nil
}

func main() {

}

// 在测试文件中进行模拟

type mockConnection struct{}

func (m *mockConnection) Channel() (Channel, error) {
	return &mockChannel{}, nil
}

func (m *mockConnection) Close() error {
	return nil
}

type mockChannel struct{}

func (m *mockChannel) Cancel(consumer string, noWait bool) error {
	return nil
}

//dial = func(url string) (*mockConnection, error) {
//	return &mockConnection{}, nil
//}

因此,在模拟代码中,我可以在这里使用Channel而不是*mockChannel:

func (m *mockConnection) Channel() (Channel, error) {
	return &mockChannel{}, nil
}

但显然我不能更改amqp091代码来实现这一点,因此我陷入了困境。

英文:

I'm trying to write unit tests for a pared down and slightly modified version of the Go AMQP consumer https://github.com/rabbitmq/amqp091-go/blob/main/_examples/consumer/consumer.go for a simple utility to consume messages from a RabbitMQ queue and relay them to an AWS SQS queue. I'm having difficulties in mocking things like the Connection and Channel structs - quite new to Go - any ideas on how to go about this? I wrote a gist of the code in the playground - removing most of it to get down to the salient bits. The issue is this:

https://go.dev/play/p/ybSB6EU3siO

go: finding module for package github.com/rabbitmq/amqp091-go
go: downloading github.com/rabbitmq/amqp091-go v1.8.1
go: found github.com/rabbitmq/amqp091-go in github.com/rabbitmq/amqp091-go v1.8.1
# play
./prog.go:23:16: cannot use dial(url) (value of type *amqp091.Connection) as Connection value in assignment: *amqp091.Connection does not implement Connection (wrong type for method Channel)
		have Channel() (*amqp091.Channel, error)
		want Channel() (Channel, error)

Go build failed.

The actual func I'm trying to mock:
https://github.com/rabbitmq/amqp091-go/blob/579207b03cecc66c1b206679b79f267c8c734db7/connection.go#L843

Sample code to illustrate the issue.

package main

import amqp "github.com/rabbitmq/amqp091-go"

type Channel interface {
	Cancel(consumer string, noWait bool) error
}

type Connection interface {
	Channel() (Channel, error)
	Close() error
}

type Consumer struct {
	conn    Connection
	channel Channel
	tag     string
	done    chan error
}

func (c *Consumer) Consume(url string) error {
	var err error
	c.conn, err = dial(url)
	return err
}

var dial = func(url string) (*amqp.Connection, error) {
	return &amqp.Connection{}, nil
}

func main() {

}

// mocks in a test file

type mockConnection struct{}

func (m *mockConnection) Channel() (Channel, error) {
	return &mockChannel{}, nil
}

func (m *mockConnection) Close() error {
	return nil
}

type mockChannel struct{}

func (m *mockChannel) Cancel(consumer string, noWait bool) error {
	return nil
}

//dial = func(url string) (*mockConnection, error) {
//	return &mockConnection{}, nil
//}

So in the mock code I can use Channel instead of *mockChannel here:

func (m *mockConnection) Channel() (Channel, error) {
	return &mockChannel{}, nil
}

But clearly I can't change the amqp091 code to do so thus I'm stumped.

答案1

得分: 2

我在rabbitmq/amqp091-go connection.go中看到了以下内容:

/*
Channel opens a unique, concurrent server channel to process the bulk of AMQP
messages.  Any error from methods on this receiver will render the receiver
invalid and a new Channel should be opened.
*/
func (c *Connection) Channel() (*Channel, error) {
	return c.openChannel()
}

但是你有:

type Connection interface {
    Channel() (Channel, error)
    Close() error
}

我更喜欢使用MyConnectionMyChannel,以避免任何混淆。并且使用返回(MyConnection, error)而不是(*amqp.Connection, error)dial函数:这样你就可以通过将dial函数分配为返回你的模拟实现来在测试中使用真实的amqp连接或你的模拟连接。

然后,你可以在实际代码中使用类型断言来处理需要使用真实的amqp091结构的情况。例如:

func doSomethingWithChannel(channel MyChannel) {
    if realChannel, ok := channel.(*amqp.Channel); ok {
        // 使用realChannel进行一些操作
    } else {
        // 处理mockChannel
    }
}

这样你就可以通过模拟ConnectionChannel接口来编写单元测试,同时仍然能够在实际代码中使用真实的amqp091结构。

然而...这意味着你的测试代码泄漏到了应用代码中,这不是最佳实践。

避免这种情况的一种方法是让你的生产代码和测试代码都实现相同的接口,然后在实际代码中,只与这个接口进行交互。这样,实际代码不知道它是在处理真实实现还是模拟实现,而且你的实际应用程序中没有任何测试代码泄漏。

package main

import (
    "fmt"
    amqp "github.com/rabbitmq/amqp091-go"
)

// 定义接口
type MyChannel interface {
    Cancel(consumer string, noWait bool) error
}

type MyConnection interface {
    Channel() (MyChannel, error)
    Close() error
}

// Consumer 结构体
type Consumer struct {
    conn    MyConnection
    channel MyChannel
    tag     string
    done    chan error
}

func (c *Consumer) Consume(url string) error {
    var err error
    c.conn, err = dial(url)
    if err != nil {
        return err
    }
    c.channel, err = c.conn.Channel()
    return err
}

// 使用函数变量来定义dial,这样在测试中可以被覆盖
var dial = func(url string) (MyConnection, error) {
    conn, err := amqp.Dial(url)
    if err != nil {
        return nil, err
    }
    return &AMQPConnection{conn}, nil
}

// 包装真实的amqp类型以实现你的接口
type AMQPConnection struct {
    *amqp.Connection
}

func (a *AMQPConnection) Channel() (MyChannel, error) {
    ch, err := a.Connection.Channel()
    if err != nil {
        return nil, err
    }
    return &AMQPChannel{ch}, nil
}

type AMQPChannel struct {
    *amqp.Channel
}

func (a *AMQPChannel) Cancel(consumer string, noWait bool) error {
    return a.Channel.Cancel(consumer, noWait)
}

func main() {
    c := &Consumer{}
    err := c.Consume("amqp://guest:guest@localhost:5672/")
    fmt.Println(err)
}

// 在单独的_test.go文件中

type mockConnection struct{}

func (m *mockConnection) Channel() (MyChannel, error) {
    return &mockChannel{}, nil
}

func (m *mockConnection) Close() error {
    return nil
}

type mockChannel struct{}

func (m *mockChannel) Cancel(consumer string, noWait bool) error {
    return nil
}

在测试中,你可以覆盖dial函数以返回模拟实现:

func TestConsumer(t *testing.T) {
    dial = func(url string) (MyConnection, error) {
        return &mockConnection{}, nil
    }
    // 其余的测试代码
}

这样,你的生产代码不知道任何关于测试代码的信息,它只通过接口进行交互,而这个接口由真实版本和模拟版本都实现。测试代码可以通过覆盖dial函数来注入模拟实现。


要在测试中模拟closeChannel方法,你实际上不需要模拟该特定方法,而是需要模拟通常调用它的结构和方法。

closeChannelConnection结构体的一个未导出方法,这意味着它不能直接从包外部访问。该方法是amqp091-go库的内部逻辑的一部分。

然而,你仍然可以通过模拟与之交互的公共方法和结构来测试依赖于该方法的行为。

在你的测试文件中:

type mockConnection struct {
    // ... 如果需要模拟连接行为,可以在这里保持状态 ...
}

func (m *mockConnection) Channel() (MyChannel, error) {
    // 模拟打开一个新的通道的行为
    return &mockChannel{}, nil
}

func (m *mockConnection) Close() error {
    // 模拟关闭连接的行为,这将在内部调用closeChannel
    return nil
}

type mockChannel struct{}

func (m *mockChannel) Close() error {
    // 模拟关闭通道的行为,这将在内部调用closeChannel
    return nil
}

func TestSomething(t *testing.T) {
    // 使用mockConnection替代真实连接
    conn := &mockConnection{}
    
    // 在这里编写你的测试逻辑,例如打开和关闭通道,并断言预期的行为
}

你的模拟实现模拟了真实的ConnectionChannel类型的行为。虽然这不能让你直接测试内部的closeChannel方法,但它允许你通过使用公共接口来测试依赖于该方法的行为。这种方法遵循了单元测试的最佳实践,即应该测试一个单元的公共接口,而不是其内部实现细节。

英文:

I see in rabbitmq/amqp091-go connection.go:

/*
Channel opens a unique, concurrent server channel to process the bulk of AMQP
messages.  Any error from methods on this receiver will render the receiver
invalid and a new Channel should be opened.
*/
func (c *Connection) Channel() (*Channel, error) {
	return c.openChannel()
}

But you have:

type Connection interface {
    Channel() (Channel, error)
    Close() error
}

I would prefer using MyConnection or MyChannel, just to avoid any confusion.
And using a dial function returning (MyConnection, error) instead of (*amqp.Connection, error): That would allow you to use either the real amqp connection or your mock connection in tests by assigning the dial function to return your mock implementation.

You can then use type assertions within your actual code to handle cases where you need to work with the real amqp091 structs.
For example:

func doSomethingWithChannel(channel MyChannel) {
    if realChannel, ok := channel.(*amqp.Channel); ok {
        // Do something with realChannel
    } else {
        // Handle mockChannel
    }
}

This allows you to write unit tests by mocking the Connection and Channel interfaces, while still being able to use the real amqp091 structs in your actual code.

However... That means your test code is leaking in your application code, which is not the best practice.

One way to avoid this is to have your production and test code both implement the same interface, and then in your actual code, you only interact with this interface.
This way, the actual code does not know anything about whether it is dealing with the real implementation or the mock, and you do not have any test code leaking into your actual application.

package main

import (
    "fmt"
    amqp "github.com/rabbitmq/amqp091-go"
)

// Define the interfaces
type MyChannel interface {
    Cancel(consumer string, noWait bool) error
}

type MyConnection interface {
    Channel() (MyChannel, error)
    Close() error
}

// Consumer struct
type Consumer struct {
    conn    MyConnection
    channel MyChannel
    tag     string
    done    chan error
}

func (c *Consumer) Consume(url string) error {
    var err error
    c.conn, err = dial(url)
    if err != nil {
        return err
    }
    c.channel, err = c.conn.Channel()
    return err
}

// Use function variable for dial so it can be overridden in tests
var dial = func(url string) (MyConnection, error) {
    conn, err := amqp.Dial(url)
    if err != nil {
        return nil, err
    }
    return &AMQPConnection{conn}, nil
}

// Wrappers around real amqp types to implement your interfaces
type AMQPConnection struct {
    *amqp.Connection
}

func (a *AMQPConnection) Channel() (MyChannel, error) {
    ch, err := a.Connection.Channel()
    if err != nil {
        return nil, err
    }
    return &AMQPChannel{ch}, nil
}

type AMQPChannel struct {
    *amqp.Channel
}

func (a *AMQPChannel) Cancel(consumer string, noWait bool) error {
    return a.Channel.Cancel(consumer, noWait)
}

func main() {
    c := &Consumer{}
    err := c.Consume("amqp://guest:guest@localhost:5672/")
    fmt.Println(err)
}

// In a separate _test.go file

type mockConnection struct{}

func (m *mockConnection) Channel() (MyChannel, error) {
    return &mockChannel{}, nil
}

func (m *mockConnection) Close() error {
    return nil
}

type mockChannel struct{}

func (m *mockChannel) Cancel(consumer string, noWait bool) error {
    return nil
}

In tests, you can override the dial function to return the mock implementations:

func TestConsumer(t *testing.T) {
    dial = func(url string) (MyConnection, error) {
        return &mockConnection{}, nil
    }
    // rest of your test code
}

That way, your production code does not have any knowledge of the test code, and it interacts only through the interface, which is implemented by both the real and mock versions.
The test code can inject the mock implementation by overriding the dial function.


To mock the closeChannel method for testing, you do not actually need to mock that specific method, but rather the structures and methods that would normally call it.

closeChannel is an unexported method of the Connection struct, which means it cannot be accessed directly from outside the package. This method is part of the internal logic of the amqp091-go library.

However, you can still test the behavior that relies on this method by mocking the public methods and structures that would interact with it.

In your test file:

type mockConnection struct {
    // ... you can keep state here if needed to simulate connection behavior ...
}

func (m *mockConnection) Channel() (MyChannel, error) {
    // Simulate behavior of opening a new channel
    return &mockChannel{}, nil
}

func (m *mockConnection) Close() error {
    // Simulate behavior of closing the connection, which would internally call closeChannel
    return nil
}

type mockChannel struct{}

func (m *mockChannel) Close() error {
    // Simulate behavior of closing the channel, which would internally call closeChannel
    return nil
}

func TestSomething(t *testing.T) {
    // Use the mockConnection in place of the real connection
    conn := &mockConnection{}
    
    // Your test logic here, e.g., opening and closing channels, and asserting the expected behavior
}

Your mock implementations simulate the behavior of the real Connection and Channel types.
Although this does not let you test the internal closeChannel method directly, it does allow you to test the behavior that relies on it by using the public interface.
This approach adheres to the best practices of unit testing, where you should test the public interface of a unit and not its internal implementation details.

huangapple
  • 本文由 发表于 2023年6月30日 23:25:25
  • 转载请务必保留本文链接:https://go.coder-hub.com/76590298.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定