MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java异常处理与单元测试

2021-05-027.7k 阅读

Java异常处理基础

在Java编程中,异常处理是确保程序健壮性和稳定性的关键机制。异常,简单来说,就是在程序执行过程中出现的错误情况。当异常发生时,如果没有适当的处理,程序可能会中断执行,导致用户体验不佳或数据丢失等问题。

Java的异常处理模型基于Throwable类,它是所有异常和错误的超类。Throwable类有两个主要的子类:Exception和Error。

Exception类

Exception类用于表示程序可以处理的异常情况。它又分为两个主要类型:检查型异常(Checked Exceptions)和非检查型异常(Unchecked Exceptions)。

  • 检查型异常:这类异常在编译时被检查。如果一个方法可能抛出检查型异常,调用该方法的代码必须显式处理这些异常,要么使用try - catch块捕获异常,要么在方法签名中声明抛出这些异常。例如,IOException是一种常见的检查型异常。当我们尝试读取文件时,如果文件不存在或无法访问,就会抛出IOException。以下是一个简单的示例:
import java.io.FileReader;
import java.io.IOException;

public class CheckedExceptionExample {
    public static void main(String[] args) {
        try {
            FileReader reader = new FileReader("nonexistentfile.txt");
        } catch (IOException e) {
            System.out.println("文件读取错误: " + e.getMessage());
        }
    }
}

在上述代码中,FileReader的构造函数可能抛出IOException,因此我们使用try - catch块来捕获并处理这个异常。如果不这样做,代码将无法通过编译。

  • 非检查型异常:这类异常包括RuntimeException及其子类,如NullPointerExceptionArrayIndexOutOfBoundsException等。它们在运行时发生,编译器不会强制要求处理。通常,非检查型异常表示程序逻辑上的错误,例如访问空对象引用或数组越界。例如:
public class UncheckedExceptionExample {
    public static void main(String[] args) {
        String str = null;
        System.out.println(str.length()); // 这里会抛出NullPointerException
    }
}

在这个例子中,由于strnull,调用length()方法时会抛出NullPointerException。这种异常不需要在编译时显式处理,但在程序设计中应尽量避免。

Error类

Error类用于表示严重的系统错误,例如OutOfMemoryError(内存溢出错误)或StackOverflowError(栈溢出错误)。这类错误通常无法由程序本身处理,因为它们代表了运行时环境的严重问题。例如:

public class ErrorExample {
    public static void main(String[] args) {
        // 不断创建对象,最终会导致OutOfMemoryError
        while (true) {
            byte[] data = new byte[1024 * 1024];
        }
    }
}

在这个例子中,不断分配内存会最终导致OutOfMemoryError

try - catch - finally语句

try - catch - finally语句是Java中处理异常的核心机制。

try块

try块包含可能会抛出异常的代码。例如:

try {
    int result = 10 / 0; // 这里会抛出ArithmeticException
    System.out.println("结果: " + result);
} catch (ArithmeticException e) {
    System.out.println("捕获到算术异常: " + e.getMessage());
}

在上述代码中,try块中的10 / 0操作会抛出ArithmeticException异常。

catch块

catch块用于捕获并处理try块中抛出的异常。一个try块可以有多个catch块,用于处理不同类型的异常。例如:

try {
    String str = null;
    int length = str.length(); // 会抛出NullPointerException
    int result = 10 / 0; // 会抛出ArithmeticException
    System.out.println("结果: " + result);
} catch (NullPointerException e) {
    System.out.println("捕获到空指针异常: " + e.getMessage());
} catch (ArithmeticException e) {
    System.out.println("捕获到算术异常: " + e.getMessage());
}

在这个例子中,首先会捕获NullPointerException,如果没有这个异常,才会尝试捕获ArithmeticException。注意,catch块的顺序很重要,子类异常的catch块应该放在父类异常的catch块之前,否则会导致编译错误。例如:

try {
    // some code
} catch (Exception e) { // 捕获所有Exception类型的异常
    // 处理代码
} catch (NullPointerException e) { // 这里会编译错误,因为NullPointerException是Exception的子类,已经被上面的catch块捕获
    // 处理代码
}

finally块

finally块无论try块中是否抛出异常,都会被执行。例如:

try {
    FileReader reader = new FileReader("example.txt");
    // 读取文件的代码
    reader.close();
} catch (IOException e) {
    System.out.println("文件读取错误: " + e.getMessage());
} finally {
    System.out.println("无论是否发生异常,我都会被执行");
}

在这个例子中,finally块中的代码会在try块执行完毕后(无论是否抛出异常)执行。finally块常用于释放资源,如关闭文件、数据库连接等。即使try块中使用了return语句,finally块也会在return之前执行。例如:

public class FinallyWithReturnExample {
    public static int test() {
        try {
            return 1;
        } finally {
            System.out.println("finally块被执行");
        }
    }

    public static void main(String[] args) {
        int result = test();
        System.out.println("结果: " + result);
    }
}

在这个例子中,finally块中的代码会在return 1之前执行,输出结果为:

finally块被执行
结果: 1

自定义异常

在实际开发中,我们可能需要定义自己的异常类型来表示特定的业务逻辑错误。自定义异常需要继承Exception类(如果是检查型异常)或RuntimeException类(如果是非检查型异常)。

例如,假设我们有一个银行账户类,当账户余额不足时,我们希望抛出一个自定义异常。

// 自定义检查型异常
class InsufficientFundsException extends Exception {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount > balance) {
            throw new InsufficientFundsException("余额不足");
        }
        balance -= amount;
    }

    public double getBalance() {
        return balance;
    }
}

public class CustomExceptionExample {
    public static void main(String[] args) {
        BankAccount account = new BankAccount(1000);
        try {
            account.withdraw(1500);
        } catch (InsufficientFundsException e) {
            System.out.println("取款失败: " + e.getMessage());
        }
    }
}

在上述代码中,我们定义了InsufficientFundsException检查型异常。BankAccount类的withdraw方法在余额不足时抛出这个异常,调用者必须使用try - catch块来处理它。

如果我们想定义一个非检查型自定义异常,可以继承RuntimeException类:

// 自定义非检查型异常
class NegativeAmountException extends RuntimeException {
    public NegativeAmountException(String message) {
        super(message);
    }
}

class Transaction {
    public void process(double amount) {
        if (amount < 0) {
            throw new NegativeAmountException("金额不能为负数");
        }
        // 处理交易的代码
    }
}

public class UncheckedCustomExceptionExample {
    public static void main(String[] args) {
        Transaction transaction = new Transaction();
        transaction.process(-100); // 会抛出NegativeAmountException
    }
}

在这个例子中,NegativeAmountException是非检查型异常,调用process方法时如果传入负数金额,会抛出这个异常,但不需要在调用处显式处理。

Java单元测试基础

单元测试是软件开发中的重要环节,它用于测试程序中的最小可测试单元,通常是一个方法。在Java中,有多种单元测试框架可供使用,其中JUnit是最常用的框架之一。

JUnit简介

JUnit是一个开源的Java单元测试框架,它提供了一组注解和断言方法,使得编写单元测试变得简单直观。使用JUnit,我们可以创建测试类,每个测试方法用于测试目标类的一个方法。

首先,需要在项目中添加JUnit依赖。如果使用Maven,可以在pom.xml文件中添加以下依赖:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
</dependency>

如果使用Gradle,可以在build.gradle文件中添加:

testImplementation 'junit:junit:4.13.2'

编写简单的JUnit测试

假设我们有一个简单的数学运算类Calculator

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }

    public int subtract(int a, int b) {
        return a - b;
    }
}

我们可以使用JUnit来编写测试这个类的单元测试:

import org.junit.Test;
import static org.junit.Assert.*;

public class CalculatorTest {
    @Test
    public void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, result);
    }

    @Test
    public void testSubtract() {
        Calculator calculator = new Calculator();
        int result = calculator.subtract(5, 3);
        assertEquals(2, result);
    }
}

在上述代码中,我们创建了CalculatorTest测试类。每个测试方法都使用@Test注解标记,表明这是一个测试方法。assertEquals是JUnit提供的断言方法,用于验证实际结果是否与预期结果相符。

测试异常

在单元测试中,测试方法是否正确抛出异常也是很重要的一部分。JUnit提供了多种方式来测试异常。

使用expected属性

我们可以在@Test注解中使用expected属性来指定方法应该抛出的异常类型。例如,假设我们修改Calculator类,添加一个可能抛出异常的方法:

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }

    public int subtract(int a, int b) {
        return a - b;
    }

    public int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("除数不能为零");
        }
        return a / b;
    }
}

然后编写测试divide方法异常的测试用例:

import org.junit.Test;
import static org.junit.Assert.*;

public class CalculatorTest {
    @Test(expected = IllegalArgumentException.class)
    public void testDivideByZero() {
        Calculator calculator = new Calculator();
        calculator.divide(10, 0);
    }
}

在这个测试方法中,我们使用@Test(expected = IllegalArgumentException.class)指定divide方法应该抛出IllegalArgumentException异常。如果divide方法没有抛出该异常,测试将失败。

使用ExpectedException规则(JUnit 4)

JUnit 4还提供了ExpectedException规则,它提供了更灵活的方式来测试异常。例如:

import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import static org.junit.Assert.*;

public class CalculatorTest {
    @Rule
    public ExpectedException expectedException = ExpectedException.none();

    @Test
    public void testDivideByZeroWithRule() {
        Calculator calculator = new Calculator();
        expectedException.expect(IllegalArgumentException.class);
        expectedException.expectMessage("除数不能为零");
        calculator.divide(10, 0);
    }
}

在这个例子中,我们使用ExpectedException规则。首先,通过@Rule注解声明一个ExpectedException实例。然后,在测试方法中使用expect方法指定预期的异常类型,使用expectMessage方法指定预期的异常消息。这样可以更详细地验证异常的正确性。

异常处理与单元测试的结合

在实际开发中,异常处理和单元测试紧密相关。正确的异常处理可以确保程序的健壮性,而单元测试则可以验证异常处理是否正确。

例如,假设我们有一个文件读取类FileReaderUtil

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class FileReaderUtil {
    public static String readFile(String filePath) {
        StringBuilder content = new StringBuilder();
        try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
            String line;
            while ((line = reader.readLine()) != null) {
                content.append(line).append("\n");
            }
        } catch (IOException e) {
            System.err.println("文件读取错误: " + e.getMessage());
            return null;
        }
        return content.toString();
    }
}

我们可以编写单元测试来验证异常处理是否正确:

import org.junit.Test;
import static org.junit.Assert.*;

public class FileReaderUtilTest {
    @Test
    public void testReadNonExistentFile() {
        String result = FileReaderUtil.readFile("nonexistentfile.txt");
        assertNull(result);
    }
}

在这个测试用例中,我们尝试读取一个不存在的文件,验证readFile方法是否正确捕获IOException并返回null。通过这样的单元测试,可以确保异常处理代码的正确性。

同时,在编写异常处理代码时,也要考虑单元测试的可测性。例如,避免在异常处理代码中进行过多复杂的逻辑操作,以免使单元测试变得困难。如果异常处理涉及到外部资源,如日志记录到文件或发送通知,可以通过依赖注入等方式将这些操作抽象出来,以便在单元测试中进行模拟。

高级异常处理与单元测试技巧

异常链

在Java中,异常链是一种将一个异常包装在另一个异常中的机制。这在我们捕获一个异常,但希望抛出另一个更适合当前上下文的异常时非常有用。例如:

class DatabaseException extends Exception {
    public DatabaseException(String message, Throwable cause) {
        super(message, cause);
    }
}

class UserService {
    public void saveUser(User user) throws DatabaseException {
        try {
            // 模拟数据库操作,可能抛出SQLException
            if (true) {
                throw new SQLException("数据库连接错误");
            }
        } catch (SQLException e) {
            throw new DatabaseException("保存用户时发生数据库错误", e);
        }
    }
}

在上述代码中,UserServicesaveUser方法捕获SQLException,并抛出DatabaseException,同时将SQLException作为DatabaseException的原因。这样,在更高层次的调用中,可以通过getCause方法获取原始异常。

在单元测试中,可以验证异常链的正确性:

import org.junit.Test;
import static org.junit.Assert.*;

public class UserServiceTest {
    @Test
    public void testSaveUserThrowsDatabaseException() {
        UserService userService = new UserService();
        try {
            userService.saveUser(null);
            fail("预期抛出DatabaseException");
        } catch (DatabaseException e) {
            assertNotNull(e.getCause());
            assertTrue(e.getCause() instanceof SQLException);
        }
    }
}

在这个测试用例中,我们验证saveUser方法抛出的DatabaseException是否包含SQLException作为原因。

模拟异常场景

在单元测试中,有时需要模拟异常场景来测试异常处理逻辑。例如,当测试一个依赖于外部服务的方法时,我们可以使用Mockito等模拟框架来模拟外部服务抛出异常。

假设我们有一个EmailService依赖于NetworkService来发送邮件:

class NetworkService {
    public void sendData(String data) throws NetworkException {
        // 实际发送数据的代码
    }
}

class EmailService {
    private NetworkService networkService;

    public EmailService(NetworkService networkService) {
        this.networkService = networkService;
    }

    public void sendEmail(String emailContent) {
        try {
            networkService.sendData(emailContent);
        } catch (NetworkException e) {
            System.err.println("发送邮件失败: " + e.getMessage());
        }
    }
}

使用Mockito可以模拟NetworkService抛出异常:

import org.junit.Test;
import org.mockito.Mockito;
import static org.mockito.Mockito.*;

public class EmailServiceTest {
    @Test
    public void testSendEmailWithNetworkException() {
        NetworkService mockNetworkService = mock(NetworkService.class);
        EmailService emailService = new EmailService(mockNetworkService);
        doThrow(new NetworkException("模拟网络异常")).when(mockNetworkService).sendData(anyString());
        emailService.sendEmail("测试邮件内容");
        verify(mockNetworkService, times(1)).sendData("测试邮件内容");
    }
}

在这个测试用例中,我们使用Mockito创建了NetworkService的模拟对象,并配置它在调用sendData方法时抛出NetworkException。然后验证sendEmail方法是否正确处理了这个异常,并验证sendData方法是否被调用了一次。

通过这些高级技巧,可以更全面地测试异常处理逻辑,确保程序在各种异常情况下的正确性和健壮性。同时,在编写异常处理代码时,也要遵循良好的编程实践,如提供清晰的异常信息、避免过度捕获异常等,以提高代码的可维护性和可读性。

在Java开发中,熟练掌握异常处理和单元测试是保证代码质量的关键。异常处理机制让我们能够优雅地处理程序运行时的错误,而单元测试则为我们提供了验证代码正确性的手段。无论是小型项目还是大型企业级应用,都应该重视这两个方面的开发工作,以打造高质量、可靠的软件产品。