Share
Embed
Unit Test your code, even your UI
Some examples of JUnit testing for Android.
Talk given for the DroidCon Paris in November 2015.
Stan Kocken
November 10, 2015
Transcript
65 slides
-
Unit Testing on JVM Run quickly Allow TDD (Test Driven
Development) Easy to add on your build process (CI) -
app ↳ src ↳ main ↳ test ↳ java ↳
com.skocken.junittest ↳ ExampleUnitTest Test hierarchy -
Build Variants Android Studio Configure View > Tools Windows >
Test Artifact: Android Instrumentation Tests Unit Tests -
Android Studio Execute app ↳ src ↳ main ↳ test
↳ java ↳ com.skocken.junittest ↳ ExampleUnitTest Right click > Run ‘All tests’ (or Run ‘Junit’) -
Gradle Execute ./gradlew testDebugUnitTest … BUILD SUCCESSFUL Total time: 2.057
secs test{flavor}{build_type}UnitTest -
app ↳ src ↳ main ↳ test ↳ java ↳
com.skocken.junittest ↳ ExampleUnitTest public class ExampleUnitTest { @Test public void addition_isCorrect() throws Exception { assertEquals(4, 2 + 2); } } OK -
TDD Add a test Run (should fail) Write code to
fix it Run (should succeed) Refactor code (Test Driven Development) -
Basic class public class Plane { private final String
mName; public Plane(String name) { mName = name; } } Alt + Enter > Create Test > JUnit4 -
Basic test public class PlaneTest { }
@Test public void testShouldCompareName() { Plane plane = new Plane("A380"); assertTrue(plane.sameAs("A380")); assertFalse(plane.sameAs("B747")); } Alt + Enter > Create method ‘sameAs’ -
Add ‘sameAs’ public class Plane { private final String
mName; public Plane(String name) { mName = name; } } public boolean sameAs(String name) { return false; } -
Run Test public class PlaneTest { @Test public void
testShouldCompareName() { Plane plane = new Plane("A380"); assertTrue(plane.sameAs("A380")); assertFalse(plane.sameAs("B747")); } } ! -
Fix ‘sameAs’ public class Plane { private final String
mName; public Plane(String name) { mName = name; } public boolean sameAs(String name) { return false; } } // return false; return name != null && name.equals(mName); -
Run Test public class PlaneTest { @Test public void
testShouldCompareName() { Plane plane = new Plane("A380"); assertTrue(plane.sameAs("A380")); assertFalse(plane.sameAs("B747")); } } OK -
Add some Android logs public class Plane { private
final String mName; public Plane(String name) { mName = name; } public boolean sameAs(String name) { return name != null && name.equals(mName); } } Log.v("Plane", "compare with: " + name); -
Run Test public class PlaneTest { @Test public void
testShouldCompareName() { Plane plane = new Plane("A380"); assertTrue(plane.sameAs("A380")); assertFalse(plane.sameAs("B747")); } } ! java.lang.RuntimeException: Method v in android.util.Log not mocked. at android.util.Log.v(Log.java) at com.skocken.junittest.Plane.sameAs(Plane.java:14) at com.skocken.junittest.PlaneTest.testShouldCompareName(PlaneTest.java:13) -
Run Test public class PlaneTest { @Test public void
testShouldCompareName() { Plane plane = new Plane("A380"); assertTrue(plane.sameAs("A380")); assertFalse(plane.sameAs("B747")); } } OK -
Run Test, with “null” public class PlaneTest { @Test
public void testShouldCompareName() { … } @Test public void testShouldCompareNameNull() { Plane plane = new Plane(null); assertTrue(plane.sameAs(null)); assertFalse(plane.sameAs("B747")); } } ! -
Fix ‘sameAs’ public class Plane { private final String
mName; public Plane(String name) { mName = name; } public boolean sameAs(String name) { Log.v("Plane", "compare with: " + name); return name != null && name.equals(mName); } } // return name != null && name.equals(mName); return TextUtils.equals(mName, name); -
Run Test public class PlaneTest { @Test public void
testShouldCompareName() { Plane plane = new Plane("A380"); assertTrue(plane.sameAs("A380")); assertFalse(plane.sameAs(“B747")); } @Test public void testShouldCompareNameNull() { Plane plane = new Plane(null); assertTrue(plane.sameAs(null)); assertFalse(plane.sameAs("B747")); } } ! -
build.gradle android { … testOptions { unitTests.returnDefaultValues = true }
} public static boolean equals(CharSequence a, CharSequence b) { … } public static boolean equals(CharSequence a, CharSequence b) { return false; } TextUtils.java Mock of TextUtils.java -
Solutions TextUtils → custom method (or library like Apache Common)
Need to replace every calls More code “duplicate” “just because” of the test Tests clean, no change -
Solutions Robolectric Not “exactly” Android A bit slower (~ +3sec)
No change on code to test -
Board play(Player player, int x, int y) Player getPlayer(int x,
int y) int getPlayLeft() Player getWinner() -
Tests public class GameBoardTest extends TestCase { private GameBoard
mSubject; @Before public void setUp() throws Exception { mSubject = new GameBoard(); } @Test public void testSetPlayer() throws Exception { assertEquals(GameBoard.Player.EMPTY, mSubject.getPlayer(0, 0)); mSubject.play(GameBoard.Player.J1, 0, 0); assertEquals(GameBoard.Player.J1, mSubject.getPlayer(0, 0)); } } Fix your tests now -
Tests public class GameBoardTest extends TestCase { private GameBoard
mSubject; @Before public void setUp() throws Exception { mSubject = new GameBoard(); } @Test public void testSetPlayer() throws Exception { … } @Test public void testGetNbPlayLeft() throws Exception { assertEquals(9, mSubject.getNbPlayLeft()); mSubject.play(GameBoard.Player.J1, 2, 2); assertEquals(8, mSubject.getNbPlayLeft()); mSubject.play(GameBoard.Player.J1, 1, 1); assertEquals(7, mSubject.getNbPlayLeft()); } } Fix your tests now -
Tests public class GameBoardTest extends TestCase { private GameBoard
mSubject; @Before public void setUp() throws Exception { mSubject = new GameBoard(); } … @Test public void testGetWinnerInCol() throws Exception { mSubject.play(GameBoard.Player.J1, 0, 0); mSubject.play(GameBoard.Player.J1, 0, 1); assertNull(mSubject.getWinner()); mSubject.play(GameBoard.Player.J1, 0, 2); assertEquals(mSubject.getWinner(), GameBoard.Player.J1); } } Fix your tests now -
Tests public class AITest extends TestCase { private static
final GameBoard.Player PLAYER_AI = GameBoard.Player.J2; private GameBoard mGameBoard; private AI mAI; @Before public void setUp() throws Exception { mGameBoard = new GameBoard(); mAI = new AI(mGameBoard, PLAYER_AI); } } -
Tests public class AITest extends TestCase { … Fix
your tests now @Test public void testPlayNextWithEmpty() throws Exception { GameBoard.Player[][] initBoard = { {__, __, __}, {__, __, __}, {__, __, __} }; mGameBoard.setBoard(initBoard); mAI.playNext(); assertEquals(PLAYER_AI, mGameBoard.getPlayer(1, 1)); } } -
Tests @Test public void testPlayWinnerMove() throws Exception { GameBoard.Player[][] initBoard
= { {J1, IA, J1}, {IA, IA, J1}, {J1, __, __} }; mGameBoard.setBoard(initBoard); mIA.playNext(); assertEquals(IA, mGameBoard.getPlayer(1, 2)); assertEquals(IA, mGameBoard.getWinner()); } } Fix your tests now public class AITest extends TestCase { … -
BaseActivity:1600+ lines A base activity that handles common functionality in
the app. This includes the navigation drawer, login and authentication, Action Bar tweaks, amongst others. -
How to do differently? View Proxy Touch Android View No
business logic DataProvider Access Data Store state Presenter Data to ViewProxy -
Architecture Presenter DataProvider ViewProxy Activity View Strong Reference Weak Reference
Other “Strong” Reference on DataProvider, Presenter or ViewProxy forbidden. Use WeakReference. OK -
v1: Activity public class OriginalActivity extends Activity implements View.OnClickListener {
private final GameBoard mGameBoard = new GameBoard(); private final GameBoard.Player mCurrentPlayer = GameBoard.Player.J1; private final AI mAI = new AI(mGameBoard, GameBoard.Player.J2); private ViewGroup mBoxesLayout; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mBoxesLayout = (ViewGroup) findViewById(R.id.boxes_layout); } @Override protected void onResume() { super.onResume(); refreshBoard(); } @Override public void onClick(View v) { Object x = v.getTag(R.id.tag_x); Object y = v.getTag(R.id.tag_y); if (x instanceof Integer && y instanceof Integer) { play((int) x, (int) y); } } private void play(int x, int y) { mGameBoard.play(mCurrentPlayer, x, y); mAI.playNext(); refreshBoard(); } private void refreshBoard() { int size = mGameBoard.getSize(); for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { GameBoard.Player player = mGameBoard.getPlayer(x, y); int boxType; switch (player) { case EMPTY: boxType = BoardDef.BOX_EMPTY; break; case J1: boxType = BoardDef.BOX_CROSS; break; case J2: boxType = BoardDef.BOX_ROUND; break; default: continue; } setBoxValue(x, y, boxType); } } } private void setBoxValue(int x, int y, int boxType) { ViewGroup rowLayout = (ViewGroup) mBoxesLayout.getChildAt(y); View boxView = rowLayout.getChildAt(x); int bgColor; switch (boxType) { case BoardDef.BOX_CROSS: bgColor = Color.RED; boxView.setOnClickListener(null); break; case BoardDef.BOX_ROUND: bgColor = Color.BLUE; boxView.setOnClickListener(null); break; default: bgColor = Color.GRAY; boxView.setOnClickListener(this); break; } boxView.setBackgroundColor(bgColor); boxView.setTag(R.id.tag_x, x); boxView.setTag(R.id.tag_y, y); } } -
DataProvider Presenter ViewProxy v2: User click box onSelectBox(int x, int
y) play(int x, int y) refreshBoard() … -
v2: Definition public interface BoardDef { int BOX_EMPTY =
0; int BOX_CROSS = 1; int BOX_ROUND = 2; interface IPresenter extends BaseDef.IPresenter { void refreshBoard(); void onSelectBox(int x, int y); } interface IDataProvider extends BaseDef.IDataProvider { GameBoard getGameBoard(); void play(int x, int y); } interface IView extends BaseDef.IView { void setBoxValue(int x, int y, int boxType); } } -
v2: DataProvider public class BoardDataProvider extends BaseDataProvider<BoardDef.IPresenter> implements BoardDef.IDataProvider {
private final GameBoard mGameBoard = new GameBoard(); private final GameBoard.Player mCurrentPlayer = GameBoard.Player.J1; private final AI mAI = new AI(mGameBoard, GameBoard.Player.J2); @Override public GameBoard getGameBoard() { return mGameBoard; } @Override public void play(int x, int y) { mGameBoard.play(mCurrentPlayer, x, y); mAI.playNext(); getPresenter().refreshBoard(); } } -
public class BoardPresenter extends BasePresenter<BoardDef.IDataProvider, BoardDef.IView> implements BoardDef.IPresenter {
public BoardPresenter(Activity activity, BoardDef.IDataProvider provider, BoardDef.IView view) { super(activity, provider, view); } @Override public void onResume() { super.onResume(); refreshBoard(); } @Override public void onSelectBox(int x, int y) { getProvider().play(x, y); } @Override public void refreshBoard() { GameBoard gameBoard = getProvider().getGameBoard(); int size = gameBoard.getSize(); for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { setBoxValue(gameBoard, y, x); } } } v2: Presenter -
public class BoardPresenter extends BasePresenter<BoardDef.IDataProvider, BoardDef.IView> implements BoardDef.IPresenter {
public BoardPresenter(Activity activity, BoardDef.IDataProvider provider, BoardDef.IView view) { super(activity, provider, view); } @Override public void onResume() { super.onResume(); refreshBoard(); } @Override public void onSelectBox(int x, int y) { getProvider().play(x, y); } @Override public void refreshBoard() { GameBoard gameBoard = getProvider().getGameBoard(); int size = gameBoard.getSize(); for (int y = 0; y < size; y++) { for (int x = 0; x < size; x++) { setBoxValue(gameBoard, y, x); } } } v2: Presenter private void setBoxValue(GameBoard gameBoard, int y, int x) { GameBoard.Player player = gameBoard.getPlayer(x, y); int boxValue; switch (player) { case EMPTY: boxValue = BoardDef.BOX_EMPTY; break; case J1: boxValue = BoardDef.BOX_CROSS; break; case J2: boxValue = BoardDef.BOX_ROUND; break; default: return; } getView().setBoxValue(x, y, boxValue); } } -
public class BoardViewProxy extends BaseViewProxy<BoardDef.IPresenter> implements BoardDef.IView, View.OnClickListener {
public BoardViewProxy(Activity activity) { super(activity); } @Override public void onClick(View v) { int x = (Integer) v.getTag(R.id.tag_x); int y = (Integer) v.getTag(R.id.tag_y); getPresenter().onSelectBox(x, y); } v2: ViewProxy @Override public void setBoxValue(int x, int y, int boxType) { ViewGroup boxLayout = findViewByIdEfficient(R.id.boxes_layout); ViewGroup rowLayout = (ViewGroup) boxLayout.getChildAt(y); View boxView = rowLayout.getChildAt(x); int bgColor; switch (boxType) { case BoardDef.BOX_CROSS: bgColor = Color.RED; boxView.setOnClickListener(null); break; case BoardDef.BOX_ROUND: bgColor = Color.BLUE; boxView.setOnClickListener(null); break; -
public class BoardViewProxy extends BaseViewProxy<BoardDef.IPresenter> implements BoardDef.IView, View.OnClickListener {
public BoardViewProxy(Activity activity) { super(activity); } @Override public void onClick(View v) { int x = (Integer) v.getTag(R.id.tag_x); int y = (Integer) v.getTag(R.id.tag_y); getPresenter().onSelectBox(x, y); } v2: ViewProxy @Override public void setBoxValue(int x, int y, int boxType) { ViewGroup boxLayout = findViewByIdEfficient(R.id.boxes_layout); ViewGroup rowLayout = (ViewGroup) boxLayout.getChildAt(y); View boxView = rowLayout.getChildAt(x); int bgColor; switch (boxType) { case BoardDef.BOX_CROSS: bgColor = Color.RED; boxView.setOnClickListener(null); break; case BoardDef.BOX_ROUND: bgColor = Color.BLUE; boxView.setOnClickListener(null); break; default: bgColor = Color.GRAY; boxView.setOnClickListener(this); break; } boxView.setBackgroundColor(bgColor); boxView.setTag(R.id.tag_x, x); boxView.setTag(R.id.tag_y, y); } } -
Improve clarity To test it easily! Why split my god
object Activity? -
Tests: setup public class BoardDataProviderTest extends TestCase { private
BoardDef.IPresenter mPresenter; private BoardDataProvider mDataProvider; @Before public void setUp() throws Exception { mPresenter = Mockito.mock(BoardDef.IPresenter.class); mDataProvider = new BoardDataProvider(); mDataProvider.setPresenter(mPresenter); } -
Tests: setup public class BoardPresenterTest extends TestCase { private
BoardDef.IDataProvider mDataProvider; private BoardDef.IView mBoardView; private BoardPresenter mBoardPresenter; @Before public void setUp() throws Exception { mDataProvider = Mockito.mock(BoardDef.IDataProvider.class); mBoardView = Mockito.mock(BoardDef.IView.class); mBoardPresenter = new BoardPresenter(mDataProvider, mBoardView); } -
Tests: setup public class BoardViewProxyTest extends TestCase { private
View mContentView; private BoardDef.IPresenter mPresenter; private BoardViewProxy mViewProxy; @Before public void setUp() throws Exception { mPresenter = Mockito.mock(BoardDef.IPresenter.class); mContentView = Mockito.mock(View.class); Activity mockActivity = Mockito.mock(Activity.class); when(mockActivity.findViewById(android.R.id.content)) .thenReturn(mContentView); mViewProxy = new BoardViewProxy(mockActivity); mViewProxy.setPresenter(mPresenter); } -
Test the UI Android stuffs TextView ImageView … View Proxy
Already Tested by framework team -
Tests ViewProxy @Test public void testSetBoxValue() throws Exception { ViewGroup
boxesViewGroup = Mockito.mock(ViewGroup.class); when(mContentView.findViewById(R.id.boxes_layout)).thenReturn(boxesViewGroup); ViewGroup firstRowViewGroup = Mockito.mock(ViewGroup.class); ViewGroup secondRowViewGroup = Mockito.mock(ViewGroup.class); ViewGroup thirdRowViewGroup = Mockito.mock(ViewGroup.class); when(boxesViewGroup.getChildAt(0)).thenReturn(firstRowViewGroup); when(boxesViewGroup.getChildAt(1)).thenReturn(secondRowViewGroup); when(boxesViewGroup.getChildAt(2)).thenReturn(thirdRowViewGroup); View firstRowFirstColView = Mockito.mock(View.class); View secondRowSecondColView = Mockito.mock(View.class); View thirdRowSecondColView = Mockito.mock(View.class); when(firstRowViewGroup.getChildAt(0)).thenReturn(firstRowFirstColView); when(secondRowViewGroup.getChildAt(1)).thenReturn(secondRowSecondColView); when(thirdRowViewGroup.getChildAt(1)).thenReturn(thirdRowSecondColView); mViewProxy.setBoxValue(0, 0, BoardDef.BOX_CROSS); mViewProxy.setBoxValue(1, 1, BoardDef.BOX_ROUND); mViewProxy.setBoxValue(1, 2, BoardDef.BOX_EMPTY); assertBackgroundColor(Color.RED, firstRowFirstColView); assertBackgroundColor(Color.BLUE, secondRowSecondColView); assertBackgroundColor(Color.GRAY, thirdRowSecondColView); } private void assertBackgroundColor(int expectedColor, View view) { ArgumentCaptor<Integer> colorArg = ArgumentCaptor.forClass(Integer.class); verify(view).setBackgroundColor(colorArg.capture()); assertEquals("The background expectedColor is not correct", expectedColor, (int) colorArg.getValue()); } -
Code Coverage Highlight part of code not tested The percentage
is (quite) useless Display in Android Studio -
Android Studio Execute app ↳ src ↳ main ↳ test
↳ java ↳ com.skocken.junittest ↳ ExampleUnitTest Right click > Run ‘All tests’ with Coverage (or Run ‘Junit’ with Coverage) -
Common Singleton patterns public class MyManager { private static
class SingletonHolder { private static final MyManager INSTANCE = new MyManager(); } private MyManager() { } public static MyManager getInstance() { return SingletonHolder.INSTANCE; } public void myMethod() { } } Testable? // or public enum MyManager { INSTANCE; public void myMethod() { } } -
My testable Singleton pattern public class MyManager { private
MyManager() { } public static MyManager getInstance() { return Singleton.getInstance(MyManager.class); } public void myMethod() { } } -
Test public class GameBoard { ... public void
myMethod() { MyManager.getInstance().myMethod(); } } public class GameBoardTest extends TestCase { ... @Test public void testShouldCallMockSingleton() { MyManager managerMock = Mockito.mock(MyManager.class); Singleton.setInstance(MyManager.class, managerMock); mSubject.myMethod(); verify(managerMock, times(1)).myMethod(); } } -
Credits @stan_kocken Presentation pattern: github.com/StanKocken/Presentation TicTacToe Example: github.com/StanKocken/TicTacToe Singleton: github.com/StanKocken/39d604bcf8c814a48859
Leak Canary: github.com/square/leakcanary Presentation pattern co-writer: Marc Olory