如何在 Java 中测试抽象超类

How to test an abstract superclass in Java

我是一名学习软件开发的学生(第一年),我们使用的教学语言是 Java。我们已经涵盖了基础知识和大部分 OOP,但我一直在练习制作商店管理系统,我遇到了一些我无法想象的事情。

我正在尝试 单元测试 两个 classes,它们 both abstract superclasses根据下面的 UML

,我计划实施的其他几个 classes

Person Superclass and Employee subclass - both abstract

我在这里阅读了一系列 post,我看到很多人都在推荐诸如 power mock 和 mockito 之类的东西来制作模拟对象。我可能试图一次学习太多东西,但基本上我在单元测试 class 中使用了具体的“包装器”私有 classes,我用来多态地创建 Employee 对象(技术上EmployeeWrapper 对象),然后通过包装器 class.

对所有 public 方法进行单元测试

我对“糟糕的代码味道”一词非常熟悉,这真的很糟糕。是否有一种标准方法可以在不使用 Mockito 和 Power Mock 之类的情况下测试抽象 superclasses?还是我只需要吸收它并使用类似的东西?

这是 classes 的代码(删除了所有方法体,因此您不必通读一大堆不重要的细节

    import java.time.LocalDateTime;
    import java.util.Hashtable;
    import java.util.Iterator;

    public abstract class Employee extends Person {

        private double hourlyRate;
        private double hoursPerWeek;
        private LocalDateTime dateOfEmploymentStart;
        private LocalDateTime dateOfEmploymentEnd;
        private Hashtable<LocalDateTime, Integer> shifts;

        private static final double MINIMUM_WAGE = 8.0;

        /**
         * Constructor for Employee for all fields except dateOfHire, which is set to {@code LocalDateTime.now()}
         * 
         * @param name
         * @param email
         * @param phoneNumber
         * @param hourlyRate
         * @param weeklyHours
         * @throws IllegalArgumentException if name if blank or null
         */
        public Employee(String name, String email, String phoneNumber, double hourlyRate, double weeklyHours) throws IllegalArgumentException {
            super(name, email, phoneNumber);
            this.setHourlyRate(hourlyRate);
            this.setWeeklyHours(weeklyHours);
            this.setDateOfEmploymentStart(LocalDateTime.now());
            this.shifts = new Hashtable<LocalDateTime, Integer>();
        }

        /**
         * Constructor for Employee that sets name, email and phoneNumber to provided args; and sets hourly rate and weeklyHours to 0
         * 
         * @param name
         * @param email
         * @param phoneNumber
         * @throws IllegalArgumentException if name is blank or null
         */
        public Employee(String name, String email, String phoneNumber) throws IllegalArgumentException {
            this(name, email, phoneNumber, MINIMUM_WAGE, 0);
        }

        /**
         * Constructor for Employee that sets only name
         * 
         * @param name
         * @throws IllegalArgumentException
         */
        public Employee(String name) throws IllegalArgumentException {
            this(name, null, null);
        }
    }

和单元测试 class(所有测试用例都删除了一个,并且那个方法主体留空 - 再次停止混乱:

    import static org.junit.jupiter.api.Assertions.*;

    import java.time.LocalDateTime;
    import java.util.Hashtable;
    import java.util.Set;

    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;

    class EmployeeTest {

        private class EmployeeWrapper extends Employee {

            public EmployeeWrapper(String name, String email, String phoneNumber, double hourlyRate, double weeklyHours) throws IllegalArgumentException {
                super(name, email, phoneNumber, hourlyRate, weeklyHours);
            }

            public EmployeeWrapper(String name, String email, String phoneNumber) throws IllegalArgumentException {
                super(name, email, phoneNumber);
            }

            public EmployeeWrapper(String name) throws IllegalArgumentException {
                super(name);
            }
        }

        private String nameValid, emailValid, phoneNumberValid;
        private String nameInvalid, emailInvalid, phoneNumberInvalid;
        private double hourlyRateValid, hourlyRateInvalidLow;
        private double weeklyHoursValid, weeklyHoursInvalid;

        private final double DEFAULT_HOURLY_RATE = 8;
        private final double DEFAULT_WEEKLY_HOURS = 0;
        private final String DEFAULT_EMAIL = "no email provided";
        private final String DEFAULT_PHONE_NUMBER = "no phone number provided";
        private final double MINIMUM_WAGE = 8.0;

        private Employee employee;

        private Hashtable<LocalDateTime, Integer> shiftsValid, shiftsInvalidEmpty;

        private LocalDateTime dateTimeValid, dateTimePast, dateTimeFuture;

        @BeforeEach
        void setUp() throws Exception {

            // valid employee
            nameValid = "testname";
            phoneNumberValid = "123456789";
            emailValid = "test@test.test.com";
            hourlyRateValid = 10.50;
            weeklyHoursValid = 7.5;

            employee = new EmployeeWrapper(nameValid, emailValid, phoneNumberValid, hourlyRateValid, weeklyHoursValid);

            // test data
            nameInvalid = "";
            emailInvalid = ".test@test.com";
            phoneNumberInvalid = "";
            hourlyRateInvalidLow = 5;
            weeklyHoursInvalid = -10;

            dateTimeValid = LocalDateTime.of(2015, 6, 15, 13, 30);
            dateTimePast = LocalDateTime.MIN;
            dateTimeFuture = LocalDateTime.MAX;

            shiftsValid = new Hashtable<LocalDateTime, Integer>();
            shiftsValid.put(dateTimeValid, 6);
            shiftsValid.put(dateTimeFuture, 3);

            shiftsInvalidEmpty = new Hashtable<LocalDateTime, Integer>();

        }

        @Test
        void testEmployeeConstructorValidAllArgs() {
        }


    }

这是我第一次 post Stack Overflow,所以如果我遗漏了任何相关细节,我深表歉意。

如果你看到我在代码中做的任何其他愚蠢的事情,我也很乐意接受任何批评!

编辑:感谢大家的回复,他们太棒了,我真的很感激!

如您所述,您无法在 Java 中测试摘要 classes。您需要一个像 Mockito 这样的模拟框架或一个扩展您的 superclass 的具体 class,在本例中为 Employee。 这就是您对 class EmployeeWrapper 所做的。除了名字(我会把它命名为 EmployeeImpl)我对你的解决方案很好。

首先让我说,你的方法是绝对可行的。我只是分享我自己的做法,因为它避免了不同实现之间常见的复制粘贴测试。

我没有专门测试摘要 classes。因为我们正在测试功能,它可以在 subclasses 中被覆盖。我将使用您的 Person class 进行此设置,但我会稍微简化一下。

public abstract class Person {

    private String name;
    private String email;

    public Person(String name, String email) {
        this.setName(name);
        this.email = email;
    }

    public String getName() {
        return this.name;
    }

    public void setName(String name) {
        if (name == null || name.isEmpty()) {
            throw new IllegalArgumentException("missing name");
        }
        this.name = name;
    }

    public String getEmail() {
        return this.email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

学生

public class Student extends Person {

    private String university;

    public Student(String name, String email, String university) {
        super(name, email);
        this.university = university;
    }

    public String getUniversity() {
        return this.university;
    }

    public void setUniversity(String university) {
        this.university = university;
    }
}

Child

public class Child extends Person {

    private String school;

    public Child(String name, String email, String school) {
        super(name, email);
        this.school = school;
    }

    public String getSchool() {
        return this.school;
    }

    public void setSchool(String school) {
        this.school = school;
    }

    @Override
    public String getName() {
        return "I am not saying!";
    }
}

所以我们有一个抽象的人,一个Student,它的具体事物是一所大学和一个Child。拥有一所学校是 child 所特有的,但它也改变了 getName() 的行为,它没有透露它的名字。这可能是需要的,但对于这个例子,我们假设像这样覆盖 getName() 是不正确的。

在处理抽象 classes 时,我做了一个抽象测试 class,它包含抽象 class - Person 提供的通用功能的通用设置和测试在这种情况下。

public abstract class PersonBaseTests {

    protected static final String EXPECTED_NAME = "George";

    private Person person;

    @BeforeEach
    public void setUp() {
        this.person = getConcretePersonImplementation();
    }

    /**
     * @return new instance of non-abstract class extending person
     */
    protected abstract Person getConcretePersonImplementation();

    //common tests
    @Test
    public void testGetName_ShouldReturnCorrectValue() {
        assertEquals(EXPECTED_NAME, this.person.getName());
    }

    @Test
    public void testConstructor_ShouldThrowIllegalArgumentExceptionOnMissingName() {
        Executable invalidConstructorInvocation = getConstructorExecutableWithMissingName();
        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, invalidConstructorInvocation);
        assertEquals("missing name", exception.getMessage());
    }

    protected abstract Executable getConstructorExecutableWithMissingName();

    //other common tests
}

扩展基础的测试classes 必须提供要测试的具体实现。它们还将继承测试,因此您无需再次编写它们。如果您还没有了解接口、lambda 和类似的东西,您可以 忽略 构造函数异常测试和与之相关的所有内容,并专注于 getName() 测试。它测试 getter 正确 returns Person 的名称。对于 Child,这显然会失败,但这就是想法。您可以添加用于获取和设置电子邮件、phone 等的测试

所以,学生测试

public class StudentTests extends PersonBaseTests {

    @Override
    protected Person getConcretePersonImplementation() {
        return new Student(PersonBaseTests.EXPECTED_NAME, "mail", "Cambridge");
    }

    @Override
    protected Executable getConstructorExecutableWithMissingName() {
        //setup invocation which will actually fail
        return new StudentConstructorExecutable(null, "email@email.email", "Stanford");
    }

    private static final class StudentConstructorExecutable implements Executable {

        private final String name;
        private final String email;
        private final String university;

        private StudentConstructorExecutable(String name, String email, String university) {
            this.name = name;
            this.email = email;
            this.university = university;
        }

        @Override
        public void execute() throws Throwable {
            //this will invoke the constructor with values from fields
            new Student(this.name, this.email, this.university);
        }
    }

    //write tests specific for student class
    //getUniversity() tests for example
}

同样,如果您还没有学会,请忽略 Executable 和所有与构造函数测试相关的内容。学生测试为常见的继承测试提供了 Student 的具体实例,您可以为特定功能编写额外的测试 - get/set university.

Child 测试

public class ChildTests extends PersonBaseTests {

    @Override
    protected Person getConcretePersonImplementation() {
        return new Child(PersonBaseTests.EXPECTED_NAME, "", "some school");
    }

    @Override
    protected Executable getConstructorExecutableWithMissingName() {
        //this can be ignored
        return () -> new Child(null, "", "");
    }

    //write tests specific for child class
    //getSchool() tests for example
}

再次为常见测试提供了一个具体实例 - 这次是 Child 类型。您可以为 Child class 提供的任何附加功能添加测试 - 在本例中获取和设置学校。然后你可以为 Person.

的每一个额外的 subclass 编写更多测试 classes

像这样,您将通用测试放在一个地方,并且您编写的抽象 class 的每个具体实现都经过完整测试,没有测试重复。关于失败的测试,如果 getName() 的行为变化是有意的,您可以在 ChildTests 中覆盖它以将其考虑在内。如果不是故意的,你知道,Student.getName() 是正确的,而 Child.getName() 不是,但你只写了一次测试。