单元测试中的ParseException

1. 问题说明

最近在给代码写单元测试,遇到了一个比较有趣的问题,问题描述如下图。


Parse Exception
java.text.ParseException: Unparseable date: "2018-04-08T21:45:00+08:00"

这是一个日期解析的错误,场景是利用Mockito和JUnit进行Android的单元测试时发生的。之所以要记录这个问题是因为在StackoverFlow上并没有看到太好的solution,所以我调查后成文,记录一下。

2. 问题原因

问题的root在这里(方法调用顺序就是代码罗列顺序):

// MeetingListBasicPresenterTest.java

// 在这个测试类的setup()方法中,需要创建一个MeetingInfoProxy的对象
// 因为MeetingInfoProxy是代理类,所以需要代理一个meeting对象,所以在inject(...)方法中传入一个meeting对象。
...
@Before
public void setup() {
  MockitoAnnotations.initMocks(this);
  PowerMockito.mockStatic(TextUtils.class, MeetingListBasicPresenter.class, DateFormat.class, Util.class);
  meetingProxy = MeetingInfoProxy.newInstance();
  meetingProxy.inject(recoverItem);
}

// MeetingInfoProxy.java

// 在MeetingInfoProxy的inject方法中,
public void inject(@NonNull ZRCMeetingListItem sourceData) {
  String st = sourceData.getStartTime();
  String et = sourceData.getEndTime();
  MeetingState ms = getMeetingState(st, et);
}

public MeetingState getMeetingState(String startTime, String endTime) {
  ...
  try {
        currentDate.setTime(System.currentTimeMillis());
        Date startDateTime = formatter.parse(startTime.replaceAll("Z$", "+00:00"));
        Date endDateTime = formatter.parse(endTime.replaceAll("Z$", "+00:00"));
       } catch(Exception e) {
           ...
       }
 ...
}

问题就出在formatter调用parse的时候,程序正常在真机上运行没有任何问题,但是在本地的JVM上运行对应单元测试就会抛异常。

3. 问题分析

发现这个问题之后,我的第一反应是没有办法来解决,只好去查。看了几个帖子,没有什么太好的方案,那么只好又重头出发,在报错的地方找找原因。

我查的第一条信息是formatter中的pattern是否与startTime的对应,也就说它能否正确的格式化时间。

private final static String DATE_PATTERN = "yyyy-MM-dd'T'HH:mm:ssZZZ";

看到我的pattern之后,我马上联想到StackoverFlow上的一个solution,我便去Android官方文档去查每个解析token对应的含义。果不其然,问题发生在Z这个token上。我们来看看到底是什么问题:

zToken.png
javaZToken.png

我在这里摆上了两份文档,上面的是Google Android的SimpleDateFormatdoc,下面的是Oracle的Java的SimpleDateFormatdoc。

我在Android Platform和Java Platform利用同一段代码分别作了实验:

try {
  String pattern = "Z";
  String tz = "+08:00";
  SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.getDefault());
  Date d = sdf.parse(tz);
} catch (ParseException e) {
  ...
}

测试结果是:在Android平台上运行不会出现问题,在Java平台上运行一定会报错。

这就很奇怪了。接着我去查parse方法的源码,对应Java和Android的版本都看了一遍,果然有收货!在android.jar包中的SimpleDateFormat.java类的subParseNumericZone方法中,有这样一段解释:

Android subParseNumberZone(...)

相信大家看到这里就应该明白了为什么在Android和Java的平台上测试结果不相同。我在下面详尽的解释一下:

问题的起因是TimeZone与pattern的格式不匹配。上文给出的pattern是格式化TimeZone的,内容是Z,这意味着Z可以格式化那些RFC 822 TimeZone的时区字符串。RFC 822 TimeZone的模板是:<i>Sign</i> <i>TwoDigitHours</i> <i>Minutes</i>,例如:“-0800”,

  • Sign :+ / -
  • TwoDigitHours:08
  • Minutes:00

也就是说,严格来说以RFC 822 TimeZone去格式化时区的话,不应该包含中间的冒号(:),所以我们在Java平台上运行上面的那一段代码的时候会抛出ParseException,因为在解析的时候遇到了不符合pattern的字符。而对于Android平台来说,不出错的原因在于SimpleDateFormat.java在android.jar(我使用的是 API 26的jar包)重新修改了subParseNumbericZone方法,虽然Z对应的RFC 822 TimeZone中不允许有冒号出现,但是如果你的TimeZone字符串中仍然有冒号,这也是允许的,并不会抛出异常,解析过程会继续向下执行,并不会break掉,而OpenJDK的版本则会中断解析并返回一个index0的值,这在上图中展示的注释中得到了验证。而无论是Unit Test还是Java Platform,SimpleDateFormat.java都是来自于OpenJDK的版本,所以这种带冒号的TimeZone字符串是无法完成格式化的。

以上就是问题出现的原因,如果您想了解更加详细的内容,可以去阅读文档和源码。

4. 解决方案

解决方案有如下几种:

  1. 如果你是在写Unit Test,像我给code写单元测试的话,实际的代码找已经指定了pattern就是Z,那么最好的方案就是将测试的TimeZone字符串改写成+0800,去掉中间的冒号,这样就严格遵守了Z的格式化。

  2. 如果可以修改源码,那么也可以调整Pattern,使用X也可以,因为X代表的是ISO 8601时区标准,它支持的模式更多一些,不过最大的限制就是Android的API了,要求是24+。

推荐阅读更多精彩内容