I'm trying to implement view tests for a Coffeescript implementation of the ubiquitous backbone.js 'todo' example (see github.com/rsim/backbone_coffeescript_demo.)
My jasmine tests of the above demo work pretty well, except for view events. I expect I am stuck on one or both of the following i) I do not understand the event binding in the view code, ii) I do not understand how to properly set up the Jasmine test of the view code events.
Here is an example of the 'edit' event...
class TodoApp.TodoView extends Backbone.View
tagName: "li"
template: TodoApp.template '#item-template'
events:
"dblclick div.todo-content" : "edit"
...
initialize: ->
_.bindAll this, 'render', 'close'
#model.bind 'change', #render
#model.bind 'destroy', => #remove()
render: ->
$(#el).html #template #model.toJSON()
#setContent()
this
edit: ->
$(#el).addClass "editing"
#input.focus()
...
...now here's a test of whether focus was gained upon double clicking:
describe "edit state", ->
li = null
beforeEach ->
setFixtures('<ul id="todo-list"></ul>')
model = new Backbone.Model id: 1, content: todoValue, done: false
view = new TodoApp.TodoView model: model, template: readFixtures("_item_template.html")
$("ul#todo-list").append(view.render().el)
li = $('ul#todo-list li:first')
target = li.find('div.todo-content')
expect(target).toExist()
target.trigger('dblclick') # here's the event!
it "input takes focus", ->
expect(li.find('.todo-input').is(':focus')).toBe(true)
The expectation on neither i) the spy nor ii) the focus is met.
Is there a peculiarity to testing backbone.js event code about which I should be aware in Jasmine?
you're spying on the view's edit method. this replaces the method with a spy object, which means the actual edit method won't get called. therefore, you're #input.focus will never fire.
since you want the test to actually call your edit method, i would remove the spy for it.
side note: don't call expect methods in your beforeEach. if you truly need to set an expectation on those, create an it block for them.
I'm not great with coffescript so I might be missing something but where are you setting up your spy?
In order to test event calling you may need to refresh the view's events once you've set up the spy.
spyOn(view, 'edit');
view.delegateEvents();
target.trigger('dblclick');
it("should call edit when target is double clicked", function() {
expect(view.edit).toHaveBeenCalled()
});
The issue with this is that Backbone.View events object is using event delegation. For the events to be able to be called work the element has to be part of DOM, you can do this by doing something like $('body').append(someView.el) in your beforeEach. Personally, I try not to test if Backbone is correctly setting the events and triggering clicks manually, is more practical for unit tests to call the callback handlers directly avoiding the DOM completely which can slow down your tests a lot.
For :focus is the same problem, there has to be an element in the DOM so that jQuery can tell if an element is focused. In this case it's better to set some state as part of your component and not checking for state via querying the DOM, e.g.: someView.hasFocus === true. Alternatively you can spy on the elements focus implementation and check if it was called.
I did not write my test in coffeescript, but I did have the same problem, so I hope you will forgive me for answering in javadcript. I ended up breaking down your test into two different tests. First, I tested if calling the view's edit function set the focus on the input box. After that, I tested whether the edit was called when the label was double-clicked, and have not yet gotten that test to pass. But here's how I tested if the edit function worked.
describe ("A todo item view", function() {
var my_model;
var todo_view;
beforeEach(function() {
my_model = new Todo({content:"todo value", done:false});
todo_view = new TodoView({model:my_model});
});
it("should set the focus on the input box when the edit function is called", function(){
$('body').append( todo_view.$el ); //append the view to Specrunner.html
todo_view.edit(); //call the view's edit function
var focus= document.activeElement; //finds what element on the page has focus
expect(focus).toBe('.todo-input'); //jasmine-jquery matcher checks if focused element has the .todo-input class
});
Something that might be causing problems is that your model And your view are declared inside beforeEach. Declaring them inside beforeEach means they only exist inside beforeEach's scope, and no longer exist when you run your it.
Also, does setFixtures do what you think it does? The focus cannot be set on an element that is not part of the DOM tree, so I appended the view's el to the body of the jasmine spec itself. (I'm using the HTML specrunner, not the command-line version) That makes it part of the dom tree and therefore allows it to have focus, and also makes whether it has focus testable.
Related
I'm trying to test a view in Backbone Marionette but the onShow() never gets called so I can't test a method that is being called in that method.
views/test.coffee
onShow: () ->
debugger # this never happens when I run the Jasmine tests
alert "HI"
spec/javascripts/views/test_spec.coffee
describe 'a test', ->
beforeEach ->
#view = new window.TestView
#view.render()
it "does something", ->
# not important
onShow() generally only gets called when you show it inside a region. There a two options you have for testing.
1) call onShow manually after render:
#view.render();
#view.onShow();
2) show the view inside a region:
You can just make a new region inside your test file, just add a detached DOM element if you don't need to use the DOM, otherwise you can just make an element and put it in the DOM.
Sidenote*** I don't know CoffeeScript, so the following might not be syntactically correct!
beforeEach ->
#view = new window.TestView
#testRegion = new Backbone.Marionette.Region({el: document.createElement('div')})
#testRegion.show(#view)
You can also trigger onShow. Sorry no CoffeeScript.
view.triggerMethod("show");
I've binded window's scroll event to a view's method like:
MyView = Backbone.View.extend({
initialize: function(){
_.bindAll(this, 'handleScrolling');
$(window).off('scroll', this.handleScrolling).on('scroll', this.handleScrolling);
}
})
I see this is not working. If this callback is triggered as many times as this view is instantiated. However, if I remove handler from off, then it is correctly unbinding and triggers only once per scrolling. Like:
$(window).off('scroll').on('scroll', this.handleScrolling);
Any idea why this is happening? I dont want to remove all callbacks from this event as other views/codes may bind event to it which will make app behaving unexpected.
Is there any better way of binding events to window/document or other element outside the scope of current view?
Your problem is right here:
_.bindAll(this, 'handleScrolling');
That's equivalent to:
this.handleScrolling = _.bind(this.handleScrolling, this);
so each time you instantiate your view, you're working with a brand new function in this.handleScrolling. Then you do this:
$(window).off('scroll', this.handleScrolling)
But that won't do anything since the this.handleScrolling function that you attached with on:
.on('scroll', this.handleScrolling);
isn't the same function as the this.handleScrolling function that you're trying to .off. The result is that each time you create a new instance of your view, you're leaving the old scroll handler in place and adding a new one.
The proper solution (IMO) is to add a remove method to properly clean things up:
remove: function() {
$(window).off('scroll', this.handleScrolling);
return Backbone.View.prototype.remove.apply(this);
}
and then call view.remove() before creating the new view.
It looks like you have a new instance of the handler this.handleScrolling in each call.
so when jQuery tries to remove the specific handler it will not find the handler in the event registry, so it will not be able to remove it.
Problem: Demo
I would suggest using event namespaces here
$(window).off('scroll.myview').on('scroll.myview', this.handleScrolling);
Demo: Fiddle
Another solution is to use a shared handler like this
My events are not loading automatically. When I added delegateEvents() at the end of the render() method, it worked for a while. I do not want to use delegateEvents, but now, even with delegateEvents the events are not loading.
I reckon the DOM is not known at the time, so the events aren't bound, but how do I check (debug) that?
View:
class EditGroup extends BaseView
initialize: ->
#render()
render: ->
html = _.template tpl, #model.toJSON()
#$el.html html
for own key, options of FormConfig[#model.type]
options.key = key
options.value = #model.get key
input = new Input options
input.on 'valuechanged', (key, value) => #model.set key, value
#$('section.'+key).html input.$el
#delegateEvents() # doesn't work
#
DOM:
h2 Edit Group
section.title
section.type
section.members
button.save.btn.btn-primary(onclick="return false") Save changes
In the sections type and member there are typeaheads and selects rendered (Backbone views). One works without delegateEvents and the other works with. The events in the parent view (shown above) don't work at all. Removing the for-loop doesn't make any difference.
Ok, I solved it, but I don't understand the workings.
I'm using a 'view manager', which registers all views and shows (read: attaches the html to the DOM) the parent view. The view manager's show function is triggered when a new route is fired, but the route was fired double, once as "route:edit" and once as "route". I catch them with router.on "all", (eventName) -> etc.. I reckon the events are bound to the html, but the html is overridden by the second router event without the bindings and attached to the DOM. Question remains why the route is fired twice.
I am trying to test drive a view event using Jasmine and the problem is probably best explained via code.
The view looks like:
App.testView = Backbone.View.extend({
events: { 'click .overlay': 'myEvent' },
myEvent: function(e) {
console.log('hello world')
}
The test looks something like:
describe('myEvent', function() {
it('should do something', function() {
var view = new App.testView();
view.myEvent();
// assertion will follow
});
});
The problem is that the view.myEvent method is never called (nothing logs to the console). I was trying to avoid triggering from the DOM. Has anyone had similar problems?
(Like I commented in the question, your code looks fine and should work. Your problem is not in the code you posted. If you can expand your code samples and give more info, we can take another look at it. What follows is more general advice on testing Backbone views.)
Calling the event handler function like you do is a legitimate testing strategy, but it has a couple of shortcomings.
It doesn't test that the events are wired up correctly. What you're testing is that the callback does what it's supposed to, but it doesn't test that the action is actually triggered when your user interacts with the page.
If your event handler needs to reference the event argument or the test will not work.
I prefer to test my views all the way from the event:
var view = new View().render();
view.$('.overlay').click();
expect(...).toEqual(...);
Like you said, it's generally not advisable to manipulate DOM in your tests, so this way of testing views requires that view.render does not attach anything to the DOM.
The best way to achieve this is leave the DOM manipulation to the code that's responsible for initializing the view. If you don't set an el property to the view (either in the View.extend definition or in the view constructor), Backbone will create a new, detached DOM node as view.el. This element works just like an attached node - you can manipulate its contents and trigger events on it.
So instead of...
View.extend({el: '#container'});
...or...
new View({el:'#container'});
...you should initialize your views as follows:
var view = new View();
$("#container").html(view.render().el);
Defining your views like this has multiple benefits:
Enables testing views fully without attaching them to DOM.
The views become reusable, you can create multiple instances and render them to different elements.
If your render method does some complicated DOM manipulation, it's faster to perform it on an detached node.
From a responsibility point of view you could argue that a view shouldn't know where it's placed, in the same way a model should not know what collection it should be added to. This enforces better design of view composition.
IMHO, this view rendering pattern is a general best practice, not just a testing-related special case.
I have a View called Form that renders either a form to edit a list, or the list itself, depending on what argument is passed to render. I've added event handlers so that the show/edit mode can be toggled. I've taken this out from the code below to keep it simple, but this just gives a bit of context to what the View does in context.
I can instantiate this Form view as a child in another view that requires a form, or the list to be rendered, which I've done in the New view, where it would be rendered as a form.
When I need to save, I call the form:save event, which triggers a routine in the Form view that saves the form, I've just made it call a console.log here to show it works. In my code, I call form:save through an $('a#submit').click binding which binds to navigation buttons that are inserted by an ApplicationView (but I don't think that matters for the purposes of this question.)
Lets say I navigate away from the New view, and I go back to it a number of times. When I hit save, the method runs the number of times I have instantiated and rendered a new Form view.
So far:
I've tried doing unbind() and remove() in a close method on the Form view from the New view with no luck.
I think I may have problems with scoping, but I'm unsure.
I know this isn't related to my navigation bindings.
I think this may be to do with zombie views.
Any pointers to make it only run once?
App.Views.New = Support.CompositeView.extend
initialize: (options) ->
_.bindAll this, 'render'
#model = new App.Models.Item()
render: ->
self = this
form = new App.Views.Form model: #model, collection: #collection
#$el.append form.render().el
setTimeout (->
$('a#submit').click (e) ->
e.preventDefault()
App.eventHandler.trigger 'form:save'
), 0
this
App.Views.Form = Support.CompositeView.extend
initialize: ->
_.bindAll this, 'render', 'save'
App.eventHandler.on 'form:save', #save
render: ->
self = this
# RENDER TEMPLATE HERE
this
save: ->
console.log 'form saved'
I believe your issue is that you are creating a new view each time you want to render the form, but you aren't getting rid of your old view. What you can do is either destroy your old view, or keep a reference to it and instead of creating a new view each time, just pass in the model to the existing view and refresh/rerender the display