จำลองตัวแปรอินสแตนซ์ / ตัวเข้าถึงสำหรับอินสแตนซ์คลาสโดยใช้ RSpec

Aug 20 2020

ในแอปพลิเคชัน Rails ของเราเรามี API ของบุคคลที่สาม (โดยใช้ Thrift) ที่เรารวมเข้ากับคลาสที่สามารถใช้หลายวิธีในการดึงข้อมูลจากอินสแตนซ์เดียวกันแล้วเพิ่มข้อมูลนั้นลงในตัวแปรอินสแตนซ์ / ตัวเข้าถึง

ตัวอย่างเช่นเรามีBookManagerคลาสดังนี้:

class BookManager
  attr_accessor :token, :books, :scope, :total_count

  def initialize(token, scope, attrs={})
    @token = token
    @scope = scope
    @books = []
    @total_count = 0
  end

  # find all books
  def find_books
    @books = API.find_books(@token, @scope)
    @total_count = @books.count
  
    self
  end

  # find a single book by book_id
  def find_book_by_id(book_id)
    @books = API.find_book_by_id(@token, @scope, book_id)

    self
  end

  # find a single book by author_id
  def find_book_by_author_id(author_id)
    @books = API.find_book_by_author_id(@token, @scope, author_id)

    self
  end
end

ดังนั้นที่นี่เราจะได้รับรายชื่อหนังสือหรือหนังสือเล่มเดียวโดยbook_idหรือauthor_idจากนั้น API จะส่งคืนข้อมูลและอินสแตนซ์ชั้นเรียนของเราจะมีหนังสือเหล่านี้

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

def with_authors(&block)
  books.each do |book|
    book.author = API.find_author_by_id(@token, @scope, book.author_id, &block)
  end

  self
end

คลาสนี้ใช้ในแอปพลิเคชันของเราดังนี้:

book_manger = BookManager.new(current_user.token, params[:scope])
                         .find_book_by_id(params[:id])
@book = book_manger.books.first

หรือถ้าเราต้องการผู้เขียนเช่นกันเราจะเชื่อมโยงวิธีการ:

book_manger = BookManager.new(current_user.token, params[:scope])
                         .find_book_by_id(params[:id])
                         .with_authors
@book = book_manger.books.first

จากนั้นเราสามารถเข้าถึงข้อมูลเช่น:

@book.book_name
@book.author.author_name

หวังว่าทั้งหมดนี้จะสมเหตุสมผลจนถึงตอนนี้ ...


ดังนั้นเมื่อเราเขียนการทดสอบ RSpec สำหรับแอปของเราเราต้องการจำลองสิ่งนี้BookManagerเพื่อไม่ให้เรียก API จริง

ตัวอย่างเช่นที่นี่ฉันได้สร้างหนังสือสองเล่มและบอกให้ RSpec ส่งคืนหนังสือ (พร้อมหนังสืออยู่ข้างใน) เมื่อมีfind_book_by_idการเรียกวิธีการ

book = double('book', book_id: 1, book_name: 'Book Name')
books = double('books', books: [book])
allow_any_instance_of(BookManager).to receive(:find_book_by_id).and_return(books)

อย่างไรก็ตามสิ่งที่ฉันพบคือbooksaccessor มักจะคืนค่าเป็นค่าเริ่มต้น[]ดังนั้นจึงไม่ได้ตั้งค่า@booksอินสแตนซ์ภายในคลาสโดยใช้การเยาะเย้ยของฉัน

แต่ฉันต้องล้อเลียน API ตัวเอง:

book = double('book', book_id: 1, book_name: 'Book Name')
books = double('books', books: [book])
allow(API).to receive(:find_book_by_id).and_return(books)

ซึ่งทำให้ฉันสามารถใช้BookManager... ซึ่งอาจเป็นที่ถกเถียงกันว่าเป็นแนวทางปฏิบัติที่ดีกว่าเนื่องจากเป็น API ที่ต้องการการเยาะเย้ย ... แต่คลาสอื่น ๆ ของเรามีวิธี API ซ้อนกันมากมายและฉันหวังว่าจะทำให้การเยาะเย้ยง่ายขึ้นและ เพียงล้อเลียนคลาสที่ใช้ในโค้ดแทนที่จะเป็นวิธีการซ้อนด้านล่าง ... ฉันก็อยากรู้เหมือนกันว่าจะทำได้ยังไง!

ฉันสมมติว่าการเยาะเย้ยBookManagerไม่ได้ผลตามที่คาดไว้เพราะฉันได้ล้อเลียนวิธีการ (ในกรณีนี้คือfind_book_by_id) which is what actual sets @books and therefore the accessor/instance variable is always empty... so in this particular case, using.and_return (หนังสือ) `ไม่ได้คืนหนังสือ ...

ดูเหมือนว่าสิ่งที่ฉันต้องทำคือส่งคืนอินสแตนซ์ของคลาสนั้นแทนที่จะเป็นเพียงbooksแต่ฉันไม่แน่ใจว่าจะทำอย่างไรโดยใช้ RSpec mocks

คำตอบ

1 aridlehoover Aug 21 2020 at 13:57

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

นอกจากนี้คุณยังมีสัญชาตญาณที่ถูกต้องที่จะไม่ล้อเลียน API หากเป้าหมายของคุณคือการทดสอบโค้ดที่ใช้โค้ดBookManagerคุณควรเยาะเย้ย / ทำลายBookManagerอินเทอร์เฟซไม่ใช่วัตถุรอง ในความเป็นจริงการทดสอบของคุณไม่ควรรู้อะไรเกี่ยวกับโครงสร้างภายในของการทดสอบBookManagerรวมถึงการรักษาสภาพหรือไม่ นั่นจะเป็นการละเมิดกฏแห่งดีมีเตอร์

แต่การทดสอบของคุณทราบเกี่ยวกับอินเทอร์เฟซสาธารณะของBookManagerรวมถึงbooksattr_accessor วิธีแก้ปัญหาของคุณคือการตัดทอนและเยาะเย้ยวิธีการอื่น ๆ ทั้งหมดด้วยวัตถุว่าง

แบบนี้:

let(:book_manager) { double(BookManager).as_null_object }
let(:book) { double('book', book_id: 1, book_name: 'Book Name') }
let(:books) { [book] }

before do
  allow(BookManager).to receive(:new).and_return(book_manager)
  allow(book_manager).to receive(:books).and_return(books)
end

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

นอกจากนี้คุณจะได้รับคะแนนโบนัสสำหรับการไม่ใช้allow_any_instance_ofซึ่งควรสงวนไว้สำหรับการทดสอบรหัสเดิมที่มีความเสี่ยงมากที่สุด

เอกสาร: https://relishapp.com/rspec/rspec-mocks/docs/basics/null-object-doubles