רמזים לבדיקות יחידה עם AssertJ

בדיקת יחידות הפכה לחלק סטנדרטי של הפיתוח. יש להשתמש בהרבה כלים לכך בדרכים שונות. מאמר זה מדגים כמה רמזים או, נאמר, יעדים טובים שעבדו עבורי.

במאמר זה, תלמד

אל תשתמש בבדיקות NPE יתר

כולנו נוטים להימנע מNullPointerException ככל האפשר בקוד הראשי כיוון שזה יכול להוביל לתוצאות מגוחכות. אני מאמין שהדאגה העיקרית שלנו אינה להימנע מ-NPE בבדיקות. המטרה שלנו היא לאמת את ההתנהגות של הרכיב הנבדק בדרך שקן, קריא ואמין.

פרקטיקה רעה

פעמים רבות בעבר, השתמשתי בהגבלה isNotNull אפילו כשזה לא נחוץ, כמו בדוגמה למטה:

Java

 

@Test
public void getMessage() {
	assertThat(service).isNotNull();
	assertThat(service.getMessage()).isEqualTo("Hello world!");
}

בדיקה זו מייצרת שגיאות כאלה:

Plain Text

 

java.lang.AssertionError: 
Expecting actual not to be null
	at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)

פרקטיקה טובה

למרות שהגבלה isNotNull הנוספת אינה באמת מזיקה, כדאי להימנע ממנה משום כך:

  • זה לא מוסיף ערך נוסף. זה רק יותר קוד לקרוא ולתחזק.
  • הבדיקה נכשלת בכל מקרה כאשר service הוא null ואנו רואים את הסיבה המשורשת האמיתית לכשלון. הבדיקה עדיין משמשת את מטרתה.
  • הודעת השגיאה שנוצרת טובה אף יותר עם ההצהרות של AssertJ.

ראה את ההצהרה המבוססת בדיקה המותאמת למטה.

Java

 

@Test
public void getMessage() {
	assertThat(service.getMessage()).isEqualTo("Hello world!");
}

הבדיקה המותאמת מייצרת שגיאה כזו:

Java

 

java.lang.NullPointerException: Cannot invoke "com.github.aha.poc.junit.spring.HelloService.getMessage()" because "this.service" is null
	at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)

הערה: דוגמא זו ניתן למצוא בSimpleSpringTest.

טענת ערכים ולא התוצאה

מדי פעם אנו כותבים בדיקה נכונה, אך בצורה "רעה". זה אומר שהבדיקה פועלת בדיוק כפי שנועדה ומאשרת את הרכיב שלנו, אך הכשל אינו מספק מספיק מידע. לכן, המטרה שלנו היא לטעון את הערך ולא את תוצאת ההשוואה.

פרקטיקה רעה

בואו נראה כמה בדיקות רעות כאלה:

Java

 

// #1
assertThat(argument.contains("o")).isTrue();

// #2
var result = "Welcome to JDK 10";
assertThat(result instanceof String).isTrue();

// #3
assertThat("".isBlank()).isTrue();

// #4
Optional<Method> testMethod = testInfo.getTestMethod();
assertThat(testMethod.isPresent()).isTrue();

חלק מהשגיאות מהבדיקות לעיל מוצגות להלן.

Plain Text

 

#1
Expecting value to be true but was false
	at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
	at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
  
#3
Expecting value to be true but was false
	at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
	at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)

פרקטיקה טובה

הפתרון הוא די פשוט עם AssertJ ו-API הזכות שלו. כל המקרים שהוזכרו לעיל יכולים להיכתב בקלות כך:

Java

 

// #1
assertThat(argument).contains("o");

// #2
assertThat(result).isInstanceOf(String.class);

// #3
assertThat("").isBlank();

// #4
assertThat(testMethod).isPresent();

אותן שגיאות שציינו קודם מספקות ערך רב יותר עכשיו.

Plain Text

 

#1
Expecting actual:
  "Hello"
to contain:
  "f" 
	at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
    
#3
Expecting blank but was: "a"
	at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)

הערה: דוגמא זו ניתן למצוא ב-SimpleParamTests.

אסוף הצהרות קשורות ביחד

שילוב ההצהרות ומתונת קידוד הקוד הקשורה מאוד עוזרת בבהירות וקריאיות הבדיקה.

ניסיון רע

כשאנו כותבים בדיקה, עשויים להגיע לבדיקה תקינה, אך פחות קריאה. נניח בדיקה שבה אנו רוצים למצוא מדינות ולבצע בדיקות אלה:

  1. לספור את המדינות שנמצאו. 
  2. לבטוח בכניסה הראשונה עם מספר ערכים.

בדיקות כאלה יכולות להיראות כמו הדוגמא הזו:

Java

 

@Test
void listCountries() {
	List<Country> result = ...;

	assertThat(result).hasSize(5);
	var country = result.get(0);
	assertThat(country.getName()).isEqualTo("Spain");
	assertThat(country.getCities().stream().map(City::getName)).contains("Barcelona");
}

ניסיון טוב

למרות שהבדיקה הקודמת תקינה, עלינו לשפר קריאיות זו מאוד על ידי אספת ההצהרות הקשורות ביחד (שורות 9-11). המטרה כאן היא לבטוח ב-result פעם אחת ולכתוב הצהרות מקושרות רבות כצורך. ראה את הגרסה המותאמת למטרה למטה.

Java

 

@Test
void listCountries() {
	List<Country> result = ...;

	assertThat(result)
		.hasSize(5)
		.singleElement()
		.satisfies(c -> {
			assertThat(c.getName()).isEqualTo("Spain");
			assertThat(c.getCities().stream().map(City::getName)).contains("Barcelona");
		});
}

הערה: דוגמא זו ניתן למצוא ב-CountryRepositoryOtherTests.

מניעת בדיקה מוצלחת כוזבת

כשמשתמשים בשיטת הטענה כלשהי עם טיעון ThrowingConsumer, אז הטיעון חייב להכיל גם את assertThat בתוך הצרכן. אחרת, הבדיקה תעבור תמיד – אפילו כשההשוואה נכשלת, מה שאומר בדיקה שגויה. הבדיקה תיכשל רק כשהטענה זורקת יוצא שגיאה של RuntimeException או AssertionError. אני מניח שזה ברור, אבל קל לשכוח את זה ולכתוב בדיקה שגויה. זה קורה לי מדי פעם.

פרקטיקה גרועה

�נניח שיש לנו מספר קודי מדינה ואנו רוצים לאמת שכל קוד מקיים תנאים מסוימים. במקרה השדות שלנו, אנו רוצים לטעון שכל קוד מדינה מכיל את התו "a". כפי שאפשר לראות, זה שטותי: יש לנו קודים באותיות גדולות, אך אנו לא מחברים חוסר רגישות לגובה בטענה.

Java

 

@Test
void assertValues() throws Exception {
	var countryCodes = List.of("CZ", "AT", "CA");
	
	assertThat( countryCodes )
		.hasSize(3)
		.allSatisfy(countryCode -> countryCode.contains("a"));
}

באופן מפתיע, הבדיקה שלנו עברה בהצלחה.

פרקטיקה טובה

כפי שציינו בתחילת הסעיף, ניתן לתקן את הבדיקה שלנו בקלות עם הוספת assertThat בצרכן (שורה 7). הבדיקה הנכונה צריכה להיות כזו:

Java

 

@Test
void assertValues() throws Exception {
	var countryCodes = List.of("CZ", "AT", "CA");
	
	assertThat( countryCodes )
		.hasSize(3)
		.allSatisfy(countryCode -> assertThat( countryCode ).containsIgnoringCase("a"));
}

כעת הבדיקה נכשלת כצפוי עם הודעת השגיאה הנכונה.

Plain Text

 

java.lang.AssertionError: 
Expecting all elements of:
  ["CZ", "AT", "CA"]
to satisfy given requirements, but these elements did not:

"CZ"
error: 
Expecting actual:
  "CZ"
to contain:
  "a"
 (ignoring case)
	at com.github.aha.sat.core.clr.AppleTest.assertValues(AppleTest.java:45)

שרשור טענות

הרעיון האחרון אינו באמת פרקטיקה, אלא המלצה. ׄAPI הזורמי של AssertJ צריך להשתמש כדי ליצור בדיקות קריאות יותר.

טענות לא משולבות

נבחן את בדיקת listLogs, שמטרתה לבדוק את התיעוד של רכיב. המטרה כאן היא לבדוק:

  • מספר מוכרז של יצירת רישומים נאסף
  • אשרור קיומו של DEBUG ו-INFO מסרי יומן
Java

 

@Test
void listLogs() throws Exception {
	ListAppender<ILoggingEvent> logAppender = ...;
	
	assertThat( logAppender.list ).hasSize(2);
	assertThat( logAppender.list ).anySatisfy(logEntry -> {
			assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
			assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
		});
	assertThat( logAppender.list ).anySatisfy(logEntry -> {
			assertThat( logEntry.getLevel() ).isEqualTo(INFO);
			assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
		});
}

קישור בין האשרורים

באמצעות ה-API הזורם שהוזכר והקישור, אנו יכולים לשנות את הבדיקה כך:

Java

 

@Test
void listLogs() throws Exception {
	ListAppender<ILoggingEvent> logAppender = ...;
	
	assertThat( logAppender.list )
		.hasSize(2)
		.anySatisfy(logEntry -> {
			assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
			assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
		})
		.anySatisfy(logEntry -> {
			assertThat( logEntry.getLevel() ).isEqualTo(INFO);
			assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
		});
}

הערה: דוגמה זו ניתן למצוא ב-AppleTest.

סיכום וקוד מקור

מסגרת AssertJ מספקת הרבה עזרה באמצעות ה-API הזורם שלהם. במאמר זה הוצגו מספר טיפים ורמזים כדי לייצר בדיקות ברורות ואמינות יותר. אנא היזהרו שרוב ההמלצות האלה הן סובייקטיביות. זה תלוי בהעדפות אישיות וסגנון הקוד.

קוד המקור המשמש ניתן למצוא במאגרי הקוד שלי:

Source:
https://dzone.com/articles/hints-for-unit-testing-with-assertj