为什么以及如何模拟Model-Helperclasses?

时间:2017-08-30 12:36:30

标签: java unit-testing dependency-injection mocking

我正在阅读有关单元测试,模拟和所有这些内容的很多内容。我目前也正在阅读这本书"以测试为导向的面向对象软件的增长#34;史蒂夫弗里曼和纳特普赖斯。

我开始理解很多东西,但遗漏了一个关键点,我试图在网上找到答案,但我还不满意。

在下面的示例中,我有一个在线商店,它接收来自第三方库的消息,翻译它们,解释它们并最终在需要时将它们保存到数据库中。在具体案例中,我收到一条消息,指出用户信用卡地址的变更,并希望将该信息存储到数据库中。

结构如下:

src/
     domain/
           MessageTranslator.java
           ShopEventListener.java
           ShopHandler.java
     model/
           CreditCard.java
           CreditCardBase.java
           CreditCardBuilder.java
           User.java
           UserBase.java
           UserBuilder.java
test/
     MessageTranslatorTest.java
     ShopHandlerTest.java

MessageTranslatorTest

public class MessageTranslatorTest {


    @Test
    public void notifiesCCAddressChangedWhenChangeCCAddressMessageReceived() throws Exception {
        ShopEventListener listenerMock = mock(ShopEventListener.class);

        MessageTranslator messageTranslator = new MessageTranslator(listenerMock);
        messageTranslator.processMessage("action=changeCCAddress; firstname=John; lastname=Doe; address=foobar3");

        verify(listenerMock).ccAddressChanged("John", "Doe", "foobar3");
    }
}

MessageTranslator (现在非常简单)

public class MessageTranslator {

    private final ShopEventListener listener;

    public MessageTranslator(ShopEventListener userEventListener) {
        listener = userEventListener;
    }

    public void processMessage(String message) throws Exception {
        String[] attributes = message.split(";");       
        listener.ccAddressChanged(attributes[1].split("=")[1].trim(), attributes[2].split("=")[1].trim(), attributes[3].split("=")[1].trim());      
    }

}

ShopHandler

public class ShopHandler implements ShopEventListener {

    @Override
    public void ccAddressChanged(String firstname, String lastname, String newAddress) throws Exception {

        // find a user (especially userid) in the Database for given firstname and lastname 
        UserBase userBase = new UserBase();
        User user = userBase.find(aUser().withFirstname(firstname).withLastname(lastname).build());

        if (user == null) {
            throw new Exception();
        }

        // find the matching CreditCard for the userid in the database
        Integer userid = user.getUserid();
        CreditCardBase ccBase = new CreditCardBase();
        CreditCard cc = ccBase.find(aCreditCard().withUserid(userid).build());

        if (cc == null) {
            throw new Exception();
        }

        // change address locally and then write it back to the database
        cc.setAddress(newAddress);
        cc.persist();
    }

}

ShopHandlerTest

public class ShopHandlerTest {

    @Test
    public void changesCCAddressWhenChangeCCAddressEventReceived() throws Exception {
        ShopHandler shop = new ShopHandler();
        shop.ccAddressChanged("John", "Doe", "foobar3");

        // TODO: How to test the changes in inner object?
    }

}

这是我总是绊倒的地方。

  1. 我是否要模拟帮助程序类UserBase和CreditCardBase不执行任何数据库查询但只返回准备好的假对象?
  2. 我是否想要模拟persist-method不将任何实际数据写入数据库,但是可能只是测试要持久化的对象的参数并让其他(集成)测试测试数据库操作?
  3. 如果1.和2.将会回答是,那我在这里测试的是什么?这个单位值得单元测试吗?
  4. 结构是否有意义?
  5. 如果1.和2.将回答是,那么我该如何模拟内部对象?我觉得依赖注入就像这里的wront方法一样,因为首先它没有真正的依赖,但是一些辅助类,第二个(更重要的是)ShopHandler类可能充斥着依赖关系,因为它可能需要很多不同的辅助类和模型用于执行所有不同操作的类。如果我只是想根据外部消息更新用户的生日,如果我仍然必须路径所有依赖项,如CreditCardBase和东西?
  6. 对于这篇长篇文章感到抱歉,如果你能把我推向正确的方向,真的很棒。 如果您需要更多代码来理解上述内容,请与我们联系。

3 个答案:

答案 0 :(得分:1)

  

我是否要模拟帮助程序类UserBase和CreditCardBase不执行任何数据库查询但只返回准备好的假对象?

看起来你的“助手类”实际上是存储库/ DAO。您通常希望与DAO分开测试业务逻辑,而无需真正的数据库访问。所以是的,您应该模拟这些DAO并准备对它们的调用,因为它们实际工作。在大多数情况下,准备好的假物体都可以。您可能还想验证您的模拟DAO是否实际被调用。

  

我是否想要模拟persist-method以不将任何实际数据写入数据库,但是可能只是测试要持久化的对象的参数并让其他(集成)测试测试数据库操作?

我发现您的业务实体中似乎有persist方法,这有点奇怪。通常DAO实现这种方法。

是的,如果您测试业务逻辑,那么您也应该模拟对{DAO}的persist调用。如果你不这样做,那么你将对业务逻辑进行更加严格的测试。

是的,你应该测试你的DAO,但要与业务逻辑分开。

  

如果1.和2.将回答是,那么我在这里测试的是什么?这个单位值得单元测试吗?

您正在测试业务逻辑。正是您在ccAddressChanged方法中实施的内容。大致是:

  • 如果找不到用户,则会抛出异常。
  • 如果找到用户但无法找到用户信用卡,则会抛出异常。
  • 如果两者都可以找到,那么信用卡会保留更新的地址。
  

这种结构是否有意义?

这不是我习惯的。你似乎在实体中有数据访问逻辑,那么你也有这个“基础”助手classess ......

  

如果1.和2.将回答是,那么我该如何模拟内部对象?

对于“内部对象”,你可能意味着这些辅助类。它们实际上更像是“辅助类”,它们是提供数据库访问权限的DAO。你可以从外面传递或注射它们。基本上这是依赖注入,您的业务逻辑依赖于这些DAO组件。如果您能够从外部传递它们,那么在测试中您可以模拟DAO并将模拟传递给您的业务服务。使用像Spring这样的DI框架,你可以获得框架支持。

这里是一个粗略的草图,展示了如何使用Spring和Mockito对ShopHandler类进行测试:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {ShopHandler.class})
public class ShopHandlerTest {

    @Autowired
    private ShopHandler sut;

    @MockBean
    private UserRepository userRepository;

    @MockBean
    private CreditCardRepository creditCardRepository;

    @Test(expected = UserNotFoundException.class)
    public void throwsUserNotFoundExceptionIfUserIsUnknown() {
        when(userRepository.findUserByFirstNameAndLastName("Scott", "Tiger").thenReturn(null);

        sut.ccAddressChanged("Scott", "Tiger", "Some Address");
    }

    @Test
    public void successFullyUpdatesCreditCardAddress() {
        when(userRepository.findUserByFirstNameAndLastName("Scott", "Tiger").thenReturn(new User("userId", ...));
        when(creditCardRepository.findByUserId("userId")).thenReturn(new CreditCard(...));

        ArgumentCaptor<CreditCard> creditCardCaptor = ArgumentCaptor.forClass(CreditCard.class);

        verify(creditCardRepository).save(creditCardCaptor.capture());

        sut.ccAddressChanged("Scott", "Tiger", "Some Address");

        asserthThat(creditCardCaptor.getValue().getAddress()).isEqualTo("Some Address");
    }
}
  

我觉得依赖注入就是这里的方法,

依赖注入是一种非常明智的方法。

  

因为首先它没有真正的依赖,

嗯,当然这些都是真正的依赖。

  

但是有些辅助类,

你认为它最终会成为“帮助者类”并开始成为“真正的依赖”?你所谓的“辅助类”非常类似于绝对是“真正依赖”的DAO。

  第二个(也就是更重要的是)ShopHandler类可能充斥着依赖关系,因为它可能需要很多不同的辅助类和模型类来执行所有不同的操作。

如果您需要执行所有这些操作并需要所有这些依赖项来执行此操作,那么这就是现实。但问题是 - 你真的必须在一个商业服务中实施所有这些行动吗?你不能把它分成许多商业服务吗?然后你会得到更小的更集中的类,并且它们只需要一些依赖。

答案 1 :(得分:0)

由于您在方法UserBase中使用CreditCard关键字创建newccAddressChanged()个实例,因此您无法嘲笑它们!

为了能够模拟它们,可以通过将这些类的实例注入ccAddressChanged()来使用DI - Dependency Injection(也称为IoC - Inversion Of Control):

更改类的签名:

public void ccAddressChanged(String firstname, String lastname, String newAddress)

为:

public void ccAddressChanged(String firstname, String lastname, String newAddress, UserBase userBase, CreditCard creditCard)

这样,您就可以模拟它们(使用Mockito或任何其他模拟框架)并将模拟发送到方法。

使用Mockito进行测试的示例:

@Test
public void changesCCAddressWhenChangeCCAddressEventReceived() throws Exception {
    ShopHandler shop = new ShopHandler();
    // mock UserBase and its behavior
    UserBase mockedUserBase = mock(UserBase.class)
    when(mockedUserBase.find(any()).thenReturns(mock(User.class));

    // mock CreditCard
    CreditCard mockedCreditCard = mock(CreditCard.class);
    shop.ccAddressChanged("John", "Doe", "foobar3");
}
  

我觉得依赖注入是错误的方法,因为   首先它没有真正的依赖,但是一些辅助类,第二个(和   更重要的是,ShopHandler类可能充斥着   依赖

DI 错误:

  1. 似乎ShopHandler确实真正依赖UserBaseCreditCardBase
  2. 为了避免“泛滥”场景,您可以将它们注入ShopHandler的构造函数并将它们保存到私有字段中。这种方式在初始化期间只执行一次,不会给用户带来负担,也不会暴露实现细节。
  3. 此外,假设您重构了代码,现在您在构造函数中分配UserBaseCreditCardBase。我会重构代码:

    @Override
    public void ccAddressChanged(String firstname, String lastname, String newAddress) throws Exception {
    
        // find a user (especially userid) in the Database for given firstname and lastname 
        UserBase userBase = new UserBase();
        User user = userBase.find(aUser().withFirstname(firstname).withLastname(lastname).build());
    
        if (user == null) {
            throw new Exception();
        }
    
        // find the matching CreditCard for the userid in the database
        Integer userid = user.getUserid();
        CreditCardBase ccBase = new CreditCardBase();
        CreditCard cc = ccBase.find(aCreditCard().withUserid(userid).build());
    
        if (cc == null) {
            throw new Exception();
        }
    
        // change address locally and then write it back to the database
        cc.setAddress(newAddress);
        cc.persist();
    }
    

    为:

    @Override
    public void ccAddressChanged(String firstname, String lastname, String newAddress) throws Exception {
        User user = getUserByName(firstname, lastname);
        CreditCard creditCard = getCCByUser(user);
        setAddress(creditCard, newAddress);
    }
    

    现在您不必再对此ccAddressChanged()进行单元测试了。您应该做的是测试,以及三种方法中的每一种:getUserByNamegetCCByUsersetAddress。而且每一个都很容易模拟和测试!

答案 2 :(得分:0)

以下是我为ShopHandler编写集成测试的方法(如问题所示,没有任何更改):

public class ShopHandlerTest {
    @Tested(fullyUnitialized = true) AppDB appDB;
    @Tested ShopHandler sut;

    @Test(expected = UserNotFoundException.class)
    public void throwsUserNotFoundExceptionIfUserIsUnknown() {
        sut.ccAddressChanged("Unknown", "user", "...");
    }

    @Test
    public void successFullyUpdatesCreditCardAddress() {
        User user = new User("Scott", "Tiger");
        appDB.persist(user);
        CreditCard cc = new CreditCard(user, ...);
        appDB.persist(cc);
        String newAddress = "New address";

        sut.ccAddressChanged(user.getFirstName(), user.getLastName(), newAddress);

        appDB.refresh(cc);
        assertEquals(newAddress, cc.getAddress());
    }
}

上面,@Tested是一个JMockit注释,支持完全DI,还有JPA / EJB / etc。支持。它可以用作元注释,因此您可以创建@SUT@TestUtil注释以简化其在测试中的使用。

完全可重用的测试实用程序类AppDB将是这样的:

public final class AppDB {
    @PersistenceContext private EntityManager em;

    @PostConstruct
    private void startTransaction() { ... using em... }

    @PreDestroy
    private void endTransaction() { ... rollback using em... }

    public void persist(Object entity) { em.persist(entity); }
    public void refresh(Object entity) { em.refresh(entity); }
}

请注意那些集成测试看起来多么简单。它们基本上只包含高级代码,基本上是您在生产(SUT)代码中看到的相同类型的代码。没有复杂的模拟API可以帮助您解决问题。它们也快速而稳定。