JUnit: สร้างชุดทดสอบ

posted on 01 Sep 2008 08:36 by wonam in softdev

ก่อนอื่นหลายคนอ่านตอนที่แล้ว อาจพบว่าเมท็อดที่จะเขียนนั้นดูง่ายเหลือเกิน (วนรอบทุก ๆ ค่าในช่วง ทดลองหารแล้วนับ) แล้วทำไมยังเสียเวลาไปทดสอบอยู่  อย่างไรก็ตามวิธีการดังกล่าวนั้นใช้เวลาเท่ากับความกว้างของช่วงที่ป้อนเข้าไป  ในการ implement เมท็อดนี้เราสนใจวิธีที่เร็วกว่านั้นน่ะครับ คือประมาณว่าจับหารเลยครับ แต่วิธีดังกล่าวนี้ดูท่าจะผิดได้ง่ายพอดู เราก็เลยมาเขียนชุดทดสอบ

กลับมาคำถามจากคราวที่แล้วนะครับ: กลุ่มของชุดทดสอบที่ดีควรเป็นอย่างไร?

ถ้าเราพิจารณาเป้าหมายของการทดสอบ ก็คือการหา bug ของโปรแกรม  ดังนั้นกลุ่มของชุดทดสอบที่ดีก็ควรมีลักษณะง่าย ๆ ก็คือทำให้เราค้นพบ bug ที่น่าสนใจ

ถ้าเป็นกระสุน ชุดทดสอบควรจะยิงไปตรงจุดที่ bug น่าจะนอนเล่นอยู

แล้ว bug ชอบนอนเล่นอยู่ที่ไหน?  ตำแหน่งแรกที่คราวนี้เราจะสนใจก็คือบริเวณที่เรียกว่าขอบ (boundary)

เรามักพบว่า bug จำนวนมากชอบอยู่บริเวณขอบ ๆ ของข้อมูลป้อนเข้า เช่น ถ้าข้อมูลป้อนเข้ามีขอบเขต -1000 ถึง 1000 ขอบของข้อมูลคือบริเวณแถว ๆ -1000, 1000 และอาจจะนับ 0 เข้าไปด้วย เพราะว่าเป็นจุดเปลี่ยนของบวกกับลบ

เริ่มกันเลยดีกว่า

เราจะทำแบบ Test-driven ไปด้วยเลยนะครับ  คือเราจะเขียนชุดทดสอบ แล้วก็ไปแก้โค้ดให้ผ่านชุดทดสอบด้วยโค้ดแบบที่ง่ายที่สุด แล้วก็ refactor ให้โปรแกรมมีโครงสร้างสวยงาม ทำสลับกันไปเรื่อย ๆ นะครับ

จากชุดทดสอบหนึ่งชุดที่แล้ว เราสามารถเขียนโค้ดให้ผ่านข้อมูลทดสอบได้ง่าย ๆ โดยสั่งให้ return 4 ตรง ๆ เลย (สังเกตว่าข้อมูลชุดทดสอบชุดเดียว บางทีแทบไม่มีประโยชน์อะไรเลย)

ดังนั้นเราจะเพิ่มชุดทดสอบอีกชุด ชุดที่แล้วเราเริ่มจากศูนย์ ทีนี้ชุดนี้เราจะเริ่มจาก 1 ถึง 10 บ้าง เราให้ชื่อว่า testRangeFromPositive โปรแกรมด้านล่างแสดงทั้งสองชุดทดสอบ (ส่วน import ขอตัดออก สามารถดูได้จากส่วนของบทความที่แล้วนะครับ)

public class DivisibilityTest {	
	@Test
	public void testRangeFromZero() {
		int res = Divisibility.countMultiplesInRange(3, 0, 10);
		assertEquals(4,res);
	}

	@Test
	public void testRangeFromPositive() {
		int res = Divisibility.countMultiplesInRange(3, 1, 10);
		assertEquals(3,res);
	}
}

ทีนี้พอกลับไปสั่งให้ทดสอบโปรแกรม เราจะพบว่าเมท็อดที่ hard-code คำตอบลงไปให้คำตอบที่ผิดแล้วนะครับ ถึงเวลาที่เราจะไปแก้ code ให้ทำงานให้ถูกต้องกัน

สังเกตว่าถ้าช่วงของเราเริ่มจาก 0 ไปจนถึง t จำนวนที่ a หารลงตัวก็จะเท่ากับ 1 + t/a โดยการหารนั้นหารโดยการปัดเศษทิ้ง (ที่บวก 1 เพิ่มเพราะว่ามีศูนย์ด้วย) ถ้าแนวคิดนี้ถูกต้อง เราสามารถทดสอบได้ไม่ยาก นั่นคือถ้าเราแก้เมท็อดที่ hard-code return 4 ให้เป็นไปตามนี้ก็น่าจะทำให้ผ่านชุดทดสอบแรกเหมือนกัน ไปทดสอบกันดีกว่าครับ (เมท็อดที่แก้แล้วแสดงด้านล่างครับ)

	static int countMultiplesInRange(int a, int f, int t) {
		return 1 + t/a;
	}

เมื่อทดลองเรียกให้ทำงาน โปรแกรมดังกล่าวผ่านชุดทดสอบแรกจริง ๆ แต่ก็ไม่ผ่านชุดทดสอบที่สองอยู่ดี อย่างไรก็ตามแนวคิดที่เราใช้สามารถขยายต่อได้ให้ครอบคลุ่มกรณีที่เราไม่ได้เริ่มที่ศูนย์

สังเกตว่า ถ้าเราไม่ได้เริ่มที่ศูนย์ ก็เหมือนกับว่าเรานับตัวที่หารลงทั้งหมดในช่วง 0 - t แล้วลบออกด้วยตัวที่หารลงทั้งหมดในช่วง 0 - f นั่นเอง คิดได้แล้วเราก็ไปแก้เมท็อดให้เป็นดังด้านล่าง

	static int countMultiplesInRange(int a, int f, int t) {
		int count_to_t = 1 + t/a;
		int count_to_f = 1 + f/a;
		return count_to_t - count_to_f;
	}

ทีนี้ พอเรารันชุดทดสอบทั้งหมด เรากลับพบว่า วิธีนี้ทำให้ชุดทดสอบที่สอง (ช่วง 1 - 10) ทำงานถูกต้อง แต่ชุดแรก (ช่วง 0 - 10) กลับผิดพลาด ถ้าดูผลลัพธ์จากการทดสอบ เราพบว่าเมท็อดตอบ 3 แทนที่จะเป็น 4 แสดงว่าเรานับขาดไปหนึ่ง แล้วตัวไหนที่ขาดล่ะ?

สังเกตว่าเมท็อดที่เราเขียนนับ 0 ขาดไป ทีนี้เราก็ไปแก้ โดยเราจะทดลองแก้ด้วยวิธีที่ง่ายที่สุดก่อนเลย ก็คือ ถ้ามันนับขาดที่ศูนย์ เราก็จัดให้ศูนย์เป็นกรณีพิเศษไปเลย (หลายคนอาจจะไม่เห็นด้วยกับการแก้แบบ "น่าเกลียด" แบบนี้ แต่เราจะค่อย ๆ แก้ไปเรื่อย นะครับ เดี๋ยวเราจะค่อย ๆ ปรับจนออกมาดูดีนะครับ --- หวังว่า??)

	static int countMultiplesInRange(int a, int f, int t) {
		int count_to_t = 1 + t/a;
		
		if(f!=0) {
			int count_to_f = 1 + f/a;
			return count_to_t - count_to_f;
		} else {
			return count_to_t;
		}
	}

เมท็อดดังกล่าวผ่านชุดทดสอบทั้งสองตามต้องการ (อย่างทะแหลแถไถ) แต่เรายังไม่เสร็จงานนะครับ เพราะว่าเราไม่แน่ใจว่าเราเก็บ bug ตามขอบหมดหรือยัง ยิ่งไปกว่านั้นถ้าเรากลับไปดูโปรแกรม test เราเห็นอะไรบางอย่าง!

สังเกตว่าโค้ดส่วนทดสอบมีส่วนที่ซ้ำกันอยู่อย่างน่าเกลียด เรา refactor เอาส่วนทดสอบออกมาใส่เป็นเมท็อด checkResult ได้ผลดังด้านล่าง

public class DivisibilityTest {	
	private void checkResult(int a, int f, int t, int expected) {
		int res = Divisibility.countMultiplesInRange(a,f,t);
		assertEquals(expected,res);
	}
	
	@Test
	public void testRangeFromZero() {
		checkResult(3,0,10,4);
	}

	@Test
	public void testRangeFromPositive() {
		checkResult(3,1,10,3);
	}
}

สังเกตว่าพอเอาออกไปแล้ว ส่วนเมท็อด testXX ของเราอ่านไม่รู้เรื่องเลย (คือไม่เห็นว่ามันเรียกอะไรทดสอบอะไร) แต่เดี๋ยวเราค่อยกลับมาแก้นะครับ ทดลองรันดูก่อนว่าโปรแกรมเรายังทำงานผ่านหรือเปล่า?

ทดลองแล้วผ่านตามคาด! โอเค งั้นเดี๋ยวเรามาแก้ให้อ่านง่ายขึ้นนะครับ โปรแกรมมันสั้นมาก จริง ๆ ผมก็ไม่รู้จะแก้ยังไงให้มันไม่กลับไปซ้ำซ้อนแบบเดิม อย่างไรก็ตามสิ่งที่ทำให้เราอ่านส่วนทดสอบยากก็คือค่าที่เราหวังผลไว้ กับข้อมูลป้อนเข้าเมท็อดมันปนกันจนแยกไม่ออก เราก็เลยแยกมันออกมาได้เป็นโปรแกรมดังด้านล่าง

public class DivisibilityTest {	
	private int resultOf(int a, int f, int t) {
		return Divisibility.countMultiplesInRange(a,f,t);
	}
	
	@Test
	public void testRangeFromZero() {
		assertEquals(4, resultOf(3,0,10));
	}

	@Test
	public void testRangeFromPositive() {
		assertEquals(3, resultOf(3,1,10));
	}
}

ค่อยอ่านง่ายขึ้นมานิดหนึ่งครับ (หมายเหตุ: ตรงนี้ผมไม่แน่ใจเหมือนกันว่า refactor แบบอื่นจะดูดีกว่าหรือเปล่า หรือว่าถ้าทิ้งไว้อย่างเดิมมันดูเป็นอย่างไร แต่ผมไม่ชอบเขียน code ยาว ๆ ผมเลยแยกส่วนที่เรียกเมท็อดที่เราต้องการทดสอบ ออกไปเป็นอีกเมท็อดหนึ่งเลย เดี๋ยวคราวถัด ๆ ไป เราจะมาดูวิธีการเขียน Parameterized test ที่เป็นการเขียนชุดทดสอบที่มีการทดสอบแบบเดียวกัน แต่มีข้อมูลป้อนเข้าหลายชุด (ซึ่งผมเองก็ยังไม่เคยลองเขียน) นะครับ อาจจะดูง่ายขึ้น)

ทีนี้ เพื่อความแน่ใจว่าเมท็อดถูก เราจะเพิ่มชุดทดสอบอีกชุด สังเกตว่าเรามีขอบเกิดขึ้นในเมท็อดที่เราเขียนก็คือขอบส่วนที่ f ไม่เท่ากับศูนย์กับเท่ากับศูนย์ ทีนี้ เราไปทดลองอีกกรณีหนึ่ง ก็คือกรณีที่ f นั้นหารลงตัวได้ด้วย a ด้วย ชุดทดสอบที่เติมเข้าไปเป็นดังด้านล่างครับ

	@Test
	public void testRangeFromPositiveDivisibleByA() {
		assertEquals(3, resultOf(3,3,10));		
	}

หลังจากทดลองเรียกให้ทำงาน เราพบว่าโปรแกรมของเราก็ยังทำงานผิดพลาดอยู่ดี

จากชุดทดสอบนี้ทำให้เราเห็นว่าปัญหาของเราในคราวก่อน ไม่ได้เกิดกับศูนย์เท่านั้น แต่ศูนย์เป็นแค่กรณีพิเศษเมื่อ f หารด้วย 3 ลงตัว ดังนั้นเรากลับไปแก้เมท็อดของเราในส่วนนี้ โดยเพิ่มให้กรณีที่ f หารด้วย 3 ลงตัวเข้าไปเป็นกรณีพิเศษ โดยรวมเข้ากับกรณี 0 เลย สังเกตว่าในกรณีนี้เรานับขาดไป 1 หรือจริง ๆ แล้วที่ count_to_f มันนับเกินไปหนึ่ง เราก็เลยไปแก้ลบ 1 ออกจากตัวแปรนั้น แก้ไปแก้มาได้ผลดังโปรแกรมด้านล่างครับ

	static int countMultiplesInRange(int a, int f, int t) {
		int count_to_t = 1 + t/a;

		int  count_to_f;
		
		if(f%a==0)
			count_to_f = f/a;
		else
			count_to_f = 1 + f/a;

		return count_to_t - count_to_f;
	}

ทีนี้โปรแกรมเราก็ทำงานผ่านชุดทดสอบทั้งหมดแล้ว แต่อีกเช่นเคย เราพบว่ามีส่วนที่ซ้ำกันในเมท็อด (ตรง f/a) เราจึงกลับไปแก้อีกหน่อยเพื่อให้เห็นความตั้งใจที่ชัดเจน พร้อมกับปรับชื่อตัวแปร (ผมติดชื่อใช้ขีดมาจาก ruby) และเปลี่ยน count_to_f เป็น countBeforeBeginning เพื่อแสดงให้เห็นว่าไม่ได้นับ f

	static int countMultiplesInRange(int a, int f, int t) {
		int countToEnd = 1 + t/a;

		int  countBeforeBeginning = 1 + f/a;
		
		if(f%a==0)
			// fix over counting f when a divides f
			countBeforeBeginning--;   

		return countToEnd - countBeforeBeginning;
	}

โปรแกรมดูดีขึ้นมาก อย่างไรก็ตาม ถ้าเราดูเงื่อนไขของการคำนวณ countBeforeBeginning ดี ๆ เราจะพบว่าเราสามารถเขียนได้โดยไม่ต้องใช้ if เลย โดยแก้เป็น

		int  countBeforeBeginning = (f+2)/a;

อย่างไรก็ตาม แม้ว่าเมื่อแก้แล้วโปรแกรมทำงานผ่านข้อมูลชุดทดสอบทั้งหมด แต่ว่าการแก้ไขดังกล่าวทำให้โค้ดที่ได้อ่านเข้าใจยากขึ้นเยอะ (มาก?) ว่าเราต้องการอะไรกันแน่ ดังนั้นผมจึงคิดว่าควรทิ้งไว้อย่างเดิม

ขั้นต่อไป: เลขลบ

อย่างไรก็ตามเรายังไม่เสร็จงาน ผมลืมบอกไปในตอนแรกว่าเมท็อดนี้จะต้องทำงานได้กับขอบเขตที่อยู่ในส่วนจำนวนเต็มลบได้ด้วย ดังนั้นผมเพิ่มชุดทดสอบเข้าไปอีกสองชุด ดังด้านล่างครับ

	@Test
	public void testRangeFromNegative() {
		assertEquals(5, resultOf(3,-5,10));		
	}

	@Test
	public void testRangeFromNegativeDivisibleByA() {
		assertEquals(5, resultOf(3,-3,10));		
	}

ตามคาดครับ เจอ bug อีกแล้ว โปรแกรมเราทำงานไม่ผ่านอยู่ 1 กรณี คือช่วง -5 ถึง 10 โปรแกรมตอบ 4 แทนที่จะเป็น 5 (น่าประหลาดใจมากที่ทำงานผ่านกรณีช่วง -3 ถึง 10 ด้วย)

อย่างไรก็ตาม entry นี้ชักยาวมากแล้ว เอาไว้ต่อคราวหน้าครับ (ถ้าใครที่แวะมาอ่านมีคำแนะนำรบกวนฝากไว้ที่ comment นะครับ)

เพิ่มเติม: refactor มากขึ้น เพื่อให้ test เข้าถึงได้มากขึ้น (3 กย. 51)

คุณ deans4j ได้ comment ไว้ด้านล่างว่า จริง ๆ ในเมท็อดที่เขียน น่าจะให้ test มันขับเคลื่อนมากกว่านี้ สังเกตว่าในส่วนที่คำนวณ countBeforeBeginning กับส่วน countToEnd มันอยู่ในเมท็อด ทำให้ไม่สามารถตรวจสอบได้ คุณ deans เลยเสนอว่าควรจะ refactor ออกมา และทดสอบเมท็อดทั้งสองนั้นด้วยครับ (ดูตัวอย่างจากใน comment นะครับ)

หลังจากที่อ่าน comment ของคุณ deans ก็เลยเห็นว่าจริง ๆ ส่วน countBeforeBeginning กับส่วน countToEnd นั้นมันทำอย่างเดียวกันอยู่ คือนับว่ามีจำนวนที่หารลงตัวกี่จำนวนโดยเริ่มจากศูนย์ (ซึ่ง countBeforeBeginning จะนับไปจนถึง f-1 เท่านั้น เดี๋ยวผมจะลอง refactor ตามที่คุณ deans เสนอแล้วเอามาแปะใน entry ใหม่ครับ

Comment

Comment:

Tweet

อ้อ จริงของคุณ deans4j ครับ ขอบคุณมากครับ

เดี๋ยวผมขอเอา comment ของคุณ deans4j ไปเขียนเติมใน entry ด้วยนะครับ

(หมายเหตุ: ผม hide comment ที่สามที่พิมพ์ไม่จบไปนะครับ)

#5 By wonam on 2008-09-03 10:54

อ่า ผมคงอธิบายสั้นไปครับ ใจความที่ผมอยากจะบอกอาจารย์คืออยากให้ดูผลลัพธ์ของการ refactoring ที่ได้จากการทำ TDD

ผมคิดว่า อ.ควรปล่อยให้ test case มัน drive ตัว unit in test มากกว่านี้ครับ

อย่างกรณีนี้ผมมองว่า อ. ค่อนข้างข้ามขั้นตอนไปนิด น่าจะ test ส่วน count_to_t กับ count_to_f ซะก่อน

ซึ่งจาก code อ. ตอนนี้มันจะ test ไม่ได้ เพราะถูกรวบไว้ใน method เดียว เลยควร refactoring แยกออกมา (static) method ต่างหาก พอแยกเป็น static method ได้ 3 อัน โค้ดจะไม่สวย ก็แยกออกเป็น class ต่างหาก

จากโค้ดผมข้างบน method countToTail, countToHead ให้ visibility เป็น default ก็ได้ครับ ไม่จำเป็นต้องเป็น public (กว้างเกิน) หรือ private (เดี๋ยวจะ test ลำบาก)

จากนั้นถ้าอ. ใน method countMultiplesInRange ค่อยสร้างเป็น

public static int countMultiplesInRange(int a,int f,int t){
return new DivisityInRangeHandler(a,f,t).count();
}

ก็ได้ครับ สำคัญคือว่าต้องให้ Test มันเป็นตัวขับเคลื่อนมากกว่านี้ครับ ถ้าส่วนไหนเริ่ม test ไม่ได้ หรือไม่คล่อง ก็ต้อง refactoring ออกไปเพื่อให้ test ได้

entry ข้างบนชี้ถึงปัญหาได้อย่างดีเลยครับ big smile พอ อ.เริ่ม test เลขติดลบ อ.รู้ได้ยังไงว่า count_to_t กับ count_to_f ได้ค่าถูกต้องหรือไม่? ถ้าไม่ debug? confused smile

#4 By deans4j (124.120.128.42) on 2008-09-03 04:12

คุณ deans4j: เพิ่งเคยเห็นรูปแบบการเขียนเป็นคลาสอีกแบบครับ ขอบคุณมากครับ ;)

#2 By wonam on 2008-09-02 05:39

ผมได้อีกแบบครับ ลองสังเกตดู คลาสนี้ผมขอละส่วน attribute กับ constructor ไว้นะครับ (เขียนสดนะครับ ถ้าผิดช่วยแก้ให้ด้วยครับ)

class DivisityInRangeHandler {
public int count(){
return countToTail() - countToHead();
}

private int countToTail(){
return 1+tail/a;
}

private int countToHead(){
return isDivisityHead() ? 1 + head/a : head/a;
}

private boolean isDivisity(){
return head%a == 0;
}
}

ในส่วน unit test อาจจะ refactoring เป็นคำสั่ง assert ไปเลยก็ได้ครับ

assertDivisityEqauls(4,new DivisityInRangeHandler(3,0,10));

public void assertDivisityEquals(int expected, DivisityInRangeHandler handler){
assertEqauls(expected,handler.count());
}

อาจจะต่างบ้างนะครับ ผมไม่ได้ใช้ static method

#1 By deans4j (124.122.142.187) on 2008-09-01 17:14