Search code examples
androidunit-testingmockkandroid-viewbindingandroidx-test

Error inflating ViewBinding in test class : Binary XML file line #38: Binary XML file line #38: Error inflating class <unknown>


I am trying to write unit tests for a RecyclerView.ViewHolder class which uses ViewBinding but I am facing issues to inflate my ViewBinding in my test class, having this error when running my test : Binary XML file line #38: Binary XML file line #38: Error inflating class <unknown> Caused by: java.lang.UnsupportedOperationException: Failed to resolve attribute at index 5: TypedValue{t=0x2/d=0x7f04015d a=2}

I could not find code examples of ViewBinding inflate in test classes, is that possible ? I found this StackOverflow thread but it uses PowerMock to mock a ViewBinding class. I'm using mockK in my project and I think using a real ViewBinding instance would be better in my case.

My ViewHolder looks like this :

class MemoViewHolder(private val binding: MemoItemBinding) : RecyclerView.ViewHolder(binding.root) {
   
    fun bind(data: Memo) {
        with(binding) {
            // doing binding with rules I would like to test
        }
    }
}

My test class looks like this. I am using MockK and Robolectric to get application context

@RunWith(RobolectricTestRunner::class)
class MemoViewHolderTest {

    private lateinit var context: MyApplication

    @Before
    fun setUp() {
        MockKAnnotations.init(this)
        context = ApplicationProvider.getApplicationContext()
    }

    @Test
    fun testSuccess() {
        val viewGroup = mockk<ViewGroup>(relaxed = true)
        val binding = MemoItemBinding.inflate(LayoutInflater.from(context), viewGroup, false)
    }
}

EDIT: This is the mockK version of the answer from @tyler-v

@RelaxedMockK
private lateinit var layoutInflater: LayoutInflater
@RelaxedMockK
private lateinit var rootView: ConstraintLayout // must be the type of the root view in the layout
@RelaxedMockK
private lateinit var groupView: ViewGroup
// mock every views in your layout
@RelaxedMockK
private lateinit var title: TextView

@Before
fun setUp() {
    context = ContextThemeWrapper(
        ApplicationProvider.getApplicationContext<MyApplication>(),
        R.style.AppTheme
    )
    MockKAnnotations.init(this)
    every { layoutInflater.inflate(R.layout.memo_item, groupView, false) } returns rootView
    every { rootView.childCount } returns 1
    every { rootView.getChildAt(0) } returns rootView
    // mock findViewById for each view in the memo_item layout
    every { rootView.findViewById<TextView>(R.id.title) } returns title
}

@After
fun tearDown() {
    unmockkAll()
}

@Test
fun testBindUser() {
    val binding = MemoItemBinding.inflate(layoutInflater, groupView, false)
    MemoListAdapter.MemoViewHolder(binding).bind(memoList[0])
    // some tests...
}

Solution

  • I was able to get this working (using Mockito, but it should be applicable to MockK too) by looking in the generated binding class to see what methods I needed to mock to get it to inflate and return mocked views properly. These files are in app/build/generated/data_binding_base_class_source_out/debug/out/your/package/databinding for a standard build

    Here is an example of a generated data binding class with three views in a ConstraintLayout.

    public final class ActivityMainBinding implements ViewBinding {
      @NonNull
      private final ConstraintLayout rootView;
    
      @NonNull
      public final Button getText;
    
      @NonNull
      public final ProgressBar progress;
    
      @NonNull
      public final TextView text;
    
      private ActivityMainBinding(@NonNull ConstraintLayout rootView, @NonNull Button getText,
          @NonNull ProgressBar progress, @NonNull TextView text) {
        this.rootView = rootView;
        this.getText = getText;
        this.progress = progress;
        this.text = text;
      }
    
      @Override
      @NonNull
      public ConstraintLayout getRoot() {
        return rootView;
      }
    
      @NonNull
      public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater) {
        return inflate(inflater, null, false);
      }
    
      @NonNull
      public static ActivityMainBinding inflate(@NonNull LayoutInflater inflater,
          @Nullable ViewGroup parent, boolean attachToParent) {
        View root = inflater.inflate(R.layout.activity_main, parent, false);
        if (attachToParent) {
          parent.addView(root);
        }
        return bind(root);
      }
    
      @NonNull
      public static ActivityMainBinding bind(@NonNull View rootView) {
        // The body of this method is generated in a way you would not otherwise write.
        // This is done to optimize the compiled bytecode for size and performance.
        int id;
        missingId: {
          id = R.id.get_text;
          Button getText = ViewBindings.findChildViewById(rootView, id);
          if (getText == null) {
            break missingId;
          }
    
          id = R.id.progress;
          ProgressBar progress = ViewBindings.findChildViewById(rootView, id);
          if (progress == null) {
            break missingId;
          }
    
          id = R.id.text;
          TextView text = ViewBindings.findChildViewById(rootView, id);
          if (text == null) {
            break missingId;
          }
    
          return new ActivityMainBinding((ConstraintLayout) rootView, getText, progress, text);
        }
        String missingId = rootView.getResources().getResourceName(id);
        throw new NullPointerException("Missing required view with ID: ".concat(missingId));
      }
    }
    

    To be able to call inflate and have the binding holding mocked views in the unit test, you need to mock several sets of calls

    @Before
    fun setUp() {
        // return the mock root from the mock inflater
        doReturn(mMockConvertView).`when`(mMockInflater).inflate(R.layout.my_layout, mMockViewGroup, false)
        
        // extra mocks to handle findChildViewById
        doReturn(1).`when`(mMockConvertView).childCount
        doReturn(mMockConvertView).`when`(mMockConvertView).getChildAt(0)
    
        // Return the mocked views
        doReturn(mMockText).`when`(mMockConvertView).findViewById<View>(R.id.text)
        doReturn(mMockButton).`when`(mMockConvertView).findViewById<View>(R.id.get_text)
        doReturn(mMockProgBar).`when`(mMockConvertView).findViewById<View>(R.id.progress)
    }
    

    They recently changed it to use ViewBindings.findChildViewById instead of just findViewById, which required extra mocking.

    @Nullable
    public static <T extends View> T findChildViewById(View rootView, @IdRes int id) {
        if (!(rootView instanceof ViewGroup)) {
            return null;
        }
        final ViewGroup rootViewGroup = (ViewGroup) rootView;
        final int childCount = rootViewGroup.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final T view = rootViewGroup.getChildAt(i).findViewById(id);
            if (view != null) {
                return view;
            }
        }
        return null;
    }
    

    Keep in mind that they may change the structure of the auto-generated code in the future, which will break unit tests like this. This happened recently, when they switched to this static method, and it would not surprise me if it happened again in the future.

    With these defined, then you could call

    val binding = ActivityMainBinding.inflate(mMockInflater, mMockViewGroup, false)
    

    to get an actual binding instance holding your mocked views.