Using Mocha to stub external APIs with minitest.

Using Mocha to stub external APIs with minitest.

Recently at ProcurementExpress.com, I had to integrate Digital invoice-matching functionality, we decided to use Mindee, Which has an accurate and lightning-fast document parsing API.

Here is the implementation we have done with Ruby on Rails. One small note, they already have https://github.com/mindee/mindee-api-ruby SDK for ruby on rails, but because our ruby version was not supported by SDK, I had to write a wrapper for it.

Here is a Ruby class I created for Mindee Document-based prediction API.

InvoiceFields is a Concern that extracts different required fields from Mindee's response.

# app/services/integrations/mindee/invoice_fields.rb

module Integrations
  module Mindee
    module InvoiceFields
      extend ActiveSupport::Concern

      def date
        value_of(:date)
      end

      def due_date
        value_of(:due_date)
      end

      def invoice_number
        value_of(:invoice_number)
      end

      def supplier_name
        value_of(:supplier_name)
      end
    end
  end
end

ConfidenceScore is another concern that returns the Average confidence score from line items.

# app/services/integrations/mindee/confidence_score.rb

module Integrations
  module Mindee
    module ConfidenceScore
      extend ActiveSupport::Concern

      # Confidence score of the average of the confidence scores of the line items
      def confidence_score
        return 0 if line_items.count.zero?

        sum_confidence = line_items.map { |item| item[:confidence] }.sum
        sum_confidence / line_items.length
      rescue StandardError => e
        puts "Error calculating confidence score: #{e.message}"
        0
      end
    end
  end
end

We are using HTTParty to make API calls, and here is the Mindee client wrapper.

# app/services/integrations/mindee/client.rb

module Integrations
  module Mindee
    class Client
      include HTTParty
      base_uri 'https://api.mindee.net/v1'
    end
  end
end

And finally, here is the predict_document.rb service class that makes an actual call to the API service.

# app/services/integrations/mindee/predict_document.rb

module Integrations
  module Mindee
    class PredictDocument
      # concerns
      include Integrations::Mindee::InvoiceFields
      include Integrations::Mindee::ConfidenceScore

      # attributes
      attr_reader :file, :error, :success, :response

      def initialize(file:)
        @file = file
        @error = nil
        @success = nil
        @token = ENV.fetch('MINDEE_API_KEY')
      end

      # Extract the invoice data from the file using the Mindee API
      def call
        return unless file.present?

        response = client.post(
          '/products/mindee/financial_document/v1/predict',
          body: { document: file },
          headers: {
            Authorization: "Token #{@token}"
          }
        )
        body = JSON.parse(response.body).with_indifferent_access

        @error =  !response.success?
        @success = response.success?
        @response = body
      end

      def line_items
        prediction[:line_items] || []
      end

      private

      def client
        @client ||= Integrations::Mindee::Client
      end

      def prediction
        @response.dig(:document, :inference, :prediction) || {}
      end

      def value_of(key)
        pred = prediction[key]

        if pred&.is_a?(Array)
          pred.first[:value]
        elsif pred&.is_a?(Hash)
          pred[:value]
        end
      end
    end
  end
end

How to Test the given service class using Mocha?


# Gemfile

group :test do
  gem 'mocha'
end

Generally, I like to make one valid API call to the service and put the actual response in the JSON file inside the test fixture, so that we can re-use that JSON in different test cases.

NOTE: Below JSON file does not contains valid JSON response

# test/fixtures/mindee_response.json

{
  "api_request": {
    ... 
  },
  "document": {
    "inference": {
      "extras": {},
      "finished_at": "2023-07-04T09:56:03.893829",
      "is_rotation_applied": true,
      "pages": [
        {
         ...   
        "line_items": [
          {
            "confidence": 0.99,
            "description": "test",
            "page_id": 0,
            "polygon": [],
            "product_code": "1",
            "quantity": 1.0,
            "tax_amount": null,
            "tax_rate": 0.0,
            "total_amount": 12.0,
            "unit_price": 12.0
          }
        ],
        "locale": {
          "confidence": 0.71,
          "currency": "EUR",
          "language": "en"
        },
        "reference_numbers": [
          {
            "confidence": 1.0,
            "page_id": 0,
            "polygon": [],
            "value": "#2534212-2023-07-03"
          }
        ],
        ...
       "started_at": "2023-07-04T09:56:02.755287"
    },
    "n_pages": 1,
    "name": "test.pdf"
  }
}

And finally, here is the mini test example:

require 'test_helper'

module Integrations
  module Mindee
    class PredictDocumentTest < ActiveSupport::TestCase
      setup do
        @response = File.read('test/files/mindee_response.json')
        @subject = Integrations::Mindee::PredictDocument
      end

      test 'should return error if response is not success' do
        Integrations::Mindee::Client.stubs(:post).returns(
          OpenStruct.new(success?: false, body: { response: { api_request: { error: 'something went wrong' } } }.to_json)
        )
        service = @subject.new(file: 'file')
        service.call
        assert service.error
      end

      test 'should return success if response is success' do
        assert stub_and_call_service.success
      end

      test 'should return the confidence score' do
        assert_equal 0.99, stub_and_call_service.confidence_score
      end

      test 'should return the invoice date' do
        assert_equal '2023-07-03', stub_and_call_service.date
      end

      test 'should return invoice items' do
        items = stub_and_call_service.line_items
        assert_equal 1, items.size
        assert_equal 'test', items.first[:description]
        assert_equal 1, items.first[:quantity]
        assert_equal 0, items.first[:tax_rate]
        assert_equal 12, items.first[:unit_price]
        assert_equal 12, items.first[:total_amount]
      end

      ...
    end
  end
end

def stub_and_call_service
  Integrations::Mindee::Client
    .stubs(:post)
    .returns(OpenStruct.new(success?: true, body: @response))
  service = @subject.new(file: 'file')
  service.call
  service
end

We can use stubs method to stub the post action from HTTParty and return our custom success or failed response. Also note that, we are directly stubbing Integrations::Mindee::Client service class and not the Integrations::Mindee::PredictDocument. That way when we call .call method it will stub the response and still allow us to test rest of the logic from the .call method, and that means, we can test both success and failure response logic.

Return Error response.

Integrations::Mindee::Client.stubs(:post).returns(
  OpenStruct.new(success?: false, body: { response: { api_request: { error: 'something went wrong' } } }.to_json)
)

Return Success response

Integrations::Mindee::Client.stubs(:post).returns(OpenStruct.new(success?: true, body: @response))
service = @subject.new(file: 'file')

Through this blog post, we have explored the key features of Mocha and demonstrated how it can be integrated with minitest to create robust test suites. We discussed the process of mocking and stubbing external APIs, enabling developers to isolate their code and test it in isolation from external factors. This approach not only enhances the reliability of tests but also accelerates development cycles by reducing the reliance on external services during the testing phase.